diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..bdee59177 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# Shared provider credentials for the whole repository. +# +# cp .env.example .env # then fill in only the providers you use +# +# This file is the FIRST of three dotenv layers the WebUI backend reads +# (webui/backend/app/core/settings.py); later files override earlier ones and a +# real process environment variable always wins: +# +# 1. /.env <- this file: shared credentials +# 2. webui/.env <- optional, WebUI-wide overrides +# 3. webui/backend/.env <- backend-only settings (see .env.example there) +# +# Every value below is also published into os.environ at startup, which is where +# the SDK's credential resolver and MCP `${VAR}` placeholders read from. +# +# Tags: [required] must be set for that provider to work · [optional] has a +# usable default. Leave a provider blank to simply not use it. +# +# WARNING: .env is git-ignored, but the SDK also writes resolved credentials in +# plaintext into /settings.json. Never share that directory. + +# --- OpenAI-compatible default ------------------------------------------- +# [optional] Consumed by the WebUI bootstrap ONLY when +# MS_AGENT_LLM_PROVIDER=openai. Point it at OpenAI itself, or at any +# OpenAI-compatible gateway together with OPENAI_BASE_URL. +OPENAI_API_KEY= +OPENAI_BASE_URL= + +# --- Alibaba DashScope (Qwen) -------------------------------------------- +# [optional] Default pairing in webui/backend/.env.example +# (MS_AGENT_LLM_PROVIDER=dashscope + MS_AGENT_LLM_MODEL=qwen3.7-plus). +DASHSCOPE_API_KEY= +DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 + +# --- ModelScope ------------------------------------------------------------ +MODELSCOPE_API_KEY= +MODELSCOPE_BASE_URL=https://api-inference.modelscope.cn/v1 + +# --- Other OpenAI-protocol providers -------------------------------------- +# [optional] Each is picked up by the SDK's credential resolver by name, so +# setting a key is all that is needed to select that provider in the UI. +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +GLM_API_KEY= +GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 +KIMI_API_KEY= +KIMI_BASE_URL=https://api.moonshot.cn/v1 +MINIMAX_API_KEY= +MINIMAX_BASE_URL=https://api.minimax.chat/v1 + +# --- Anthropic ------------------------------------------------------------- +# [optional] Also used by any provider whose `protocol` is set to "anthropic" +# (e.g. a DeepSeek /anthropic gateway). +ANTHROPIC_API_KEY= + +# --- Tools ----------------------------------------------------------------- +# [optional] Required only by the built-in web_search tool (Exa), which stays +# disabled until a key is present. +EXA_API_KEY= diff --git a/.gitignore b/.gitignore index 0f46edaa3..d602ca38a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ eggs/ .eggs/ lib/ lib64/ +# The WebUI uses `app/lib` for checked-in TypeScript application code. +!webui/frontend/app/lib/ +!webui/frontend/app/lib/** parts/ sdist/ var/ @@ -58,6 +61,7 @@ nosetests.xml coverage.xml *.cover *node_modules* +.react-router/ .hypothesis/ .pytest_cache/ @@ -89,6 +93,9 @@ target/ # pyenv .python-version +# uv reads this to pin the WebUI backend to CPython 3.12. Without it uv only +# honours requires-python (">=3.12") and can build the venv on 3.13/3.14. +!webui/backend/.python-version # celery beat schedule file celerybeat-schedule @@ -172,4 +179,9 @@ webui/work_dir/ .ms_agent_snapshots/ .ms_agent/ -webui/frontend/.react-router \ No newline at end of file +# React Router's generated route/type artefacts. +webui/frontend/.react-router + +# Chrome DevTools probes this exact path on every page load; ignore the +# file it asks for so a local 404-silencer never gets committed again. +webui/frontend/public/.well-known/appspecific/com.chrome.devtools.json diff --git a/MANIFEST.in b/MANIFEST.in index b7ac745da..8ca3c9228 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,11 +7,8 @@ recursive-include ms_agent/ *.yaml # Include projects recursive-include projects * -# Include webui backend -recursive-include webui/backend *.py - -# Include webui frontend dist (will be built during setup) -recursive-include webui/frontend/dist * +# The SSR WebUI is intentionally run from a Git source checkout. It is not +# bundled into the framework's Python sdist/wheel by the minimal launcher. # Exclude development files global-exclude *.pyc diff --git a/README.md b/README.md index 50b48f057..15dceebbf 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ MS-Agent is a lightweight framework designed to empower agents with autonomous e - **Code Generation**: Supports code generation tasks with artifacts. - **Short Video Generation**:Support video generation of about 5 minutes. - **Agent Skills**: Knowledge-driven skill system — skills provide reusable procedural knowledge that guides the model via standard tool integration, with multi-source loading, progressive disclosure, and runtime self-evolution. See [Agent Skills](ms_agent/skill/README.md). -- **WebUI**: Modern web interface for agent interaction with real-time WebSocket communication. +- **WebUI**: Local React Router and FastAPI workspace with SSE-streamed agent interaction. - **Lightweight and Extensible**: Easy to extend and customize for various applications. @@ -65,13 +65,13 @@ MS-Agent is a lightweight framework designed to empower agents with autonomous e - **Multimodal Model Input**: Support image, video, and other multimodal inputs. See [Multimodal Docs](docs/zh/Components/multimodal-support.md). * 🚀 Feb 06, 2026: Release MS-Agent v1.6.0rc1, which includes the following updates: - - **Agentic Insight v2**: A fully refactored deep-research system with better performance, scalability, and trustworthiness, now available in WebUI. See [Agentic Insight v2](https://github.com/modelscope/ms-agent/tree/main/projects/deep_research/v2). + - **Agentic Insight v2**: A fully refactored deep-research system with better performance, scalability, and trustworthiness, available in the legacy WebUI (the current WebUI has no dedicated Deep Research view — run it from the CLI). See [Agentic Insight v2](https://github.com/modelscope/ms-agent/tree/main/projects/deep_research/v2). * 🚀 Feb 04, 2026: Release MS-Agent v1.6.0rc0, which includes the following updates: - **Code Genesis** for complex code generation tasks, refer to [Code Genesis](https://github.com/modelscope/ms-agent/tree/main/projects/code_genesis) - **Singularity Cinema** for animated video generation workflow, refactored version, refer to [Singularity Cinema](https://github.com/modelscope/ms-agent/tree/main/projects/singularity_cinema) - **Agent Skills v2**: Knowledge-driven skill system — skills as procedural knowledge with progressive disclosure, multi-source loading, and standard tool integration. Refer to [Agent Skills](https://github.com/modelscope/ms-agent/tree/main/ms_agent/skill). - - **WebUI**: A new WebUI has been added, featuring agentic chatting capabilities, complex code generation and video generation workflow. + - **WebUI**: A new WebUI has been added, featuring agentic chatting capabilities, complex code generation and video generation workflow. (Superseded — see the current [WebUI guide](webui/README.md).) * 🎬 Nov 13, 2025: Release Singularity Cinema, to support short video generation for complex scenarios, check [here](projects/singularity_cinema/README_EN.md) @@ -518,51 +518,44 @@ aggregator: ### WebUI -MS-Agent provides a modern web interface for interacting with agents. Built with React frontend and FastAPI backend, featuring real-time WebSocket communication. +MS-Agent provides a local agent workspace built with a React Router frontend and a FastAPI backend. Chat responses are streamed with Server-Sent Events (SSE). -#### Demo +#### Quick Start -
- LocalGradioApplication -

Demo: WebUI

-
+The current launcher is intended for a source checkout. Install these tools first: -#### Quick Start +- [uv](https://docs.astral.sh/uv/) +- Node.js 22.22.0 or newer +- pnpm 10.x (`corepack prepare pnpm@10.17.1 --activate`) -**Start WebUI:** +From the repository root, install MS-Agent in editable mode and start the WebUI: ```bash +pip install -e . ms-agent ui ``` -**Windows tip:** If the console shows garbled text, use the PowerShell helper: +On the first run, the launcher creates the backend environment and installs the locked frontend dependencies. Later starts verify those local dependencies. The browser opens at . -```powershell -webui/scripts/start-webui.ps1 -``` - -The browser will automatically open at http://localhost:7860 +Configure a model in **Settings → Models** before starting a real chat. Environment-variable bootstrap and manual development instructions are available in the [WebUI guide](webui/README.md). -**Command Options:** +**Windows tip:** If the console shows garbled text, use the UTF-8 PowerShell helper: -| Option | Description | Default | -|--------|-------------|---------| -| `--host` | Server host | 0.0.0.0 | -| `--port` | Server port | 7860 | -| `--production` | Production mode | False | -| `--no-browser` | Don't auto-open browser | False | -| `--reload` | Enable auto-reload (dev) | False | +```powershell +.\webui\scripts\start-webui.ps1 +``` **Examples:** ```bash -# Custom port +# Use another public frontend port ms-agent ui --port 8080 -# Production mode without auto browser -ms-agent ui --production --no-browser +# Keep the browser closed +ms-agent ui --no-browser ``` +See the [complete WebUI guide](webui/README.md) for prerequisites, configuration precedence, all launcher options, Windows notes, and troubleshooting. This minimal launcher intentionally does not provide a production SSR mode.
diff --git a/README_ZH.md b/README_ZH.md index 67116a0e9..4629dfc94 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -41,6 +41,7 @@ MS-Agent是一个轻量级框架,旨在为智能体提供自主探索能力。 - **代码生成**:支持复杂项目的代码生成任务。 - **短视频生成**:支持5分钟左右的短视频生成。 - **Agent Skills**:兼容Anthropic-Agent-Skills协议,实现智能体技能模块。 +- **WebUI**:基于 React Router 和 FastAPI 的本地工作台,通过 SSE 实时返回智能体交互。 - **轻量级且可扩展**:易于扩展和定制以适应各种应用。 > 历史存档版本,请参考:https://github.com/modelscope/ms-agent/tree/0.8.0 @@ -60,13 +61,13 @@ MS-Agent是一个轻量级框架,旨在为智能体提供自主探索能力。 - **多模态模型输入**:支持图片、视频等多模态输入,详情请参考[多模态文档](docs/zh/Components/multimodal-support.md)。 * 🚀 **2026年2月6日:发布 MS-Agent v1.6.0rc1,主要更新内容如下:** - - **Agentic Insight v2**:完整重构的深度研究系统,性能更优、可扩展性更强、可信度更高,支持在webui中使用,详情请参考 [Agentic Insight v2](https://github.com/modelscope/ms-agent/tree/main/projects/deep_research/v2) + - **Agentic Insight v2**:完整重构的深度研究系统,性能更优、可扩展性更强、可信度更高,可在旧版 WebUI 中使用(当前 WebUI 没有专门的 Deep Research 入口,请用 CLI 运行),详情请参考 [Agentic Insight v2](https://github.com/modelscope/ms-agent/tree/main/projects/deep_research/v2) * 🚀 **2026年2月4日:发布 MS-Agent v1.6.0rc0,主要更新内容如下:** - **Code Genesis**:针对复杂代码生成任务的专项功能,详情请参考 [Code Genesis](https://github.com/modelscope/ms-agent/tree/main/projects/code_genesis) - **Singularity Cinema**:动画视频生成工作流的重构版本,详情请参考 [Singularity Cinema](https://github.com/modelscope/ms-agent/tree/main/projects/singularity_cinema) - **全新技能框架 (Skills Framework)**:全新设计的技能系统,显著增强了系统的健壮性与可扩展性。详情请参考 [MS-Agent Skills](https://github.com/modelscope/ms-agent/tree/main/ms_agent/skill) - - **WebUI**:新增 Web 交互界面,支持智能体对话、复杂代码生成以及视频生成工作流。 + - **WebUI**:新增 Web 交互界面,支持智能体对话、复杂代码生成以及视频生成工作流。(已被替换,当前版本见 [WebUI 完整指南](webui/README_ZH.md)。) * 🎬 2025.11.13: 发布了“奇点放映室”,用于进行知识类文档的复杂场景短视频制作,具体查看[这里](projects/singularity_cinema/README.md) @@ -558,51 +559,45 @@ OPENAI_API_KEY=xxx-xxx T2I_API_KEY=ms-xxx-xxx MANIM_TEST_API_KEY=xxx-xxx ms-agen ### WebUI -MS-Agent提供了一个简洁轻量的Web界面,用于与智能体进行交互。该界面由React前端和FastAPI后端构建,支持实时的WebSocket通信。 +MS-Agent 提供了一个本地智能体工作台,由 React Router 前端和 FastAPI 后端组成,对话通过 Server-Sent Events(SSE)实时返回。 -#### Demo +#### 快速开始 -
- LocalGradioApplication -

Demo: WebUI

-
+当前启动器面向源码仓库使用。请先安装: -#### 快速开始 +- [uv](https://docs.astral.sh/uv/) +- Node.js 22.22.0 或更高版本 +- pnpm 10.x(执行 `corepack prepare pnpm@10.17.1 --activate`) -**启动WebUI:** +在仓库根目录以 editable 模式安装 MS-Agent,然后启动 WebUI: ```bash +pip install -e . ms-agent ui ``` -**Windows 提示:** 若控制台出现乱码,建议使用 PowerShell 启动脚本: +第一次运行时,启动器会创建后端环境并安装前端锁定依赖;后续启动会校验这些本地依赖。浏览器默认打开 。 -```powershell -webui/scripts/start-webui.ps1 -``` - -浏览器打开: http://localhost:7860 +进行真实对话前,请先在 **设置 → 模型设置** 中配置模型。环境变量初始化和手动开发方式见 [WebUI 完整指南](webui/README_ZH.md)。 -**命令参数** +**Windows 提示:** 若控制台出现乱码,建议使用 UTF-8 PowerShell 启动脚本: -| 选项 | 描述 | 默认值 | -|----------------|--------------------------|---------| -| `--host` | Server host | 0.0.0.0 | -| `--port` | Server port | 7860 | -| `--production` | Production mode | False | -| `--no-browser` | Don't auto-open browser | False | -| `--reload` | Enable auto-reload (dev) | False | +```powershell +.\webui\scripts\start-webui.ps1 +``` **示例** ```bash -# Custom port +# 修改公开前端端口 ms-agent ui --port 8080 -# Production mode without auto browser -ms-agent ui --production --no-browser +# 不自动打开浏览器 +ms-agent ui --no-browser ``` +依赖、配置优先级、完整参数、Windows 注意事项和排障方式请阅读 [WebUI 完整指南](webui/README_ZH.md)。这个最简启动器有意不提供生产环境 SSR 模式。 + --- ### 有趣的工作 diff --git a/docs/en/GetStarted/CLI.md b/docs/en/GetStarted/CLI.md index 7d314815e..4feba3920 100644 --- a/docs/en/GetStarted/CLI.md +++ b/docs/en/GetStarted/CLI.md @@ -94,20 +94,24 @@ ms-agent tui --config path/to/agent.yaml ## ui — Web UI Server -Launch the Web UI server. +Launch the source-checkout WebUI development stack. The command supervises an internal FastAPI server and a public React Router development server. ```shell -ms-agent ui --host 0.0.0.0 --port 7860 +ms-agent ui ``` | Argument | Description | Default | | --- | --- | --- | -| `--host` | The server host to bind to | `0.0.0.0` | -| `--port` | The server port to bind to | `7860` | -| `--reload` | Enable auto-reload for development (flag) | `false` | -| `--production` | Run in production mode (serve built frontend, flag) | `false` | +| `--host` | Public frontend host | `127.0.0.1` | +| `--port` | Public frontend port | `7860` | +| `--backend-port` | Internal FastAPI port | `8000` | +| `--reload` | Reload the Python backend when its source changes (flag) | `false` | +| `--skip-install` | Skip dependency synchronization; requires existing `.venv` and `node_modules` (flag) | `false` | +| `--production` | Reserved compatibility flag; exits with an unsupported-mode error | `false` | | `--no-browser` | Do not automatically open the browser (flag) | `false` | +The launcher requires uv, Node.js 22.22.0 or newer, and pnpm 10.x. It installs locked project-local dependencies on first use. See the [WebUI guide](https://github.com/modelscope/ms-agent/blob/main/webui/README.md) for setup, model configuration, environment variables, Windows support, and troubleshooting. + --- ## app — Gradio App diff --git a/docs/zh/GetStarted/cli.md b/docs/zh/GetStarted/cli.md index fa678bbec..0b5768254 100644 --- a/docs/zh/GetStarted/cli.md +++ b/docs/zh/GetStarted/cli.md @@ -91,20 +91,24 @@ ms-agent tui --config path/to/agent.yaml ## ui — Web UI 服务 -启动 Web UI 服务。 +启动源码仓库中的 WebUI 开发栈。该命令同时管理内部 FastAPI 服务和公开的 React Router 开发服务。 ```shell -ms-agent ui --host 0.0.0.0 --port 7860 +ms-agent ui ``` | 参数 | 说明 | 默认值 | | --- | --- | --- | -| `--host` | 绑定的服务主机 | `0.0.0.0` | -| `--port` | 绑定的服务端口 | `7860` | -| `--reload` | 开发模式启用自动重载(开关) | `false` | -| `--production` | 生产模式(服务已构建的前端,开关) | `false` | +| `--host` | 公开前端绑定的主机 | `127.0.0.1` | +| `--port` | 公开前端端口 | `7860` | +| `--backend-port` | 内部 FastAPI 端口 | `8000` | +| `--reload` | 后端源码变化时自动重载(开关) | `false` | +| `--skip-install` | 跳过依赖同步;要求 `.venv` 和 `node_modules` 已存在(开关) | `false` | +| `--production` | 兼容性保留参数;当前会提示不支持并退出 | `false` | | `--no-browser` | 不自动打开浏览器(开关) | `false` | +启动器要求 uv、Node.js 22.22.0 或更高版本以及 pnpm 10.x,并会在首次使用时安装锁定的项目内依赖。安装、模型配置、环境变量、Windows 支持与排障说明见 [WebUI 完整指南](https://github.com/modelscope/ms-agent/blob/main/webui/README_ZH.md)。 + --- ## app — Gradio 应用 diff --git a/ms_agent/cli/ui.py b/ms_agent/cli/ui.py index b10c9bb5d..4e4d0fa52 100644 --- a/ms_agent/cli/ui.py +++ b/ms_agent/cli/ui.py @@ -1,23 +1,75 @@ # Copyright (c) ModelScope Contributors. All rights reserved. +"""Launch the source-checkout WebUI development stack.""" + +from __future__ import annotations + import argparse import os +import re +import shutil +import signal +import socket +import subprocess import sys -import threading import time +import urllib.error +import urllib.request import webbrowser from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple from .base import CLICommand +DEFAULT_HOST = '127.0.0.1' +DEFAULT_PORT = 7860 +DEFAULT_BACKEND_PORT = 8000 +DEFAULT_STARTUP_TIMEOUT = 120.0 +IS_WINDOWS = os.name == 'nt' +CREATE_NEW_PROCESS_GROUP = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', + 0x00000200) +CTRL_BREAK_EVENT = getattr(signal, 'CTRL_BREAK_EVENT', 1) +MIN_NODE_VERSION = (22, 22, 0) +# The floor is set by the flags _ensure_dependencies passes: `--locked` and +# `--inexact`. Probed by the launcher rather than delegated to +# `[tool.uv] required-version` alone, because uv's own refusal reaches us only +# as an exit code from _run_setup — with no version and no path in the message, +# which is exactly the ambiguity this check exists to remove. +MIN_UV_VERSION = (0, 5, 0) + +# Health checks target loopback, so they must never traverse a proxy. The +# default opener installs ProxyHandler(getproxies()), and on macOS getproxies() +# also reads System Configuration — so an active VPN or a debugging proxy +# applies with no environment variable set, and 127.0.0.1 is NOT bypassed unless +# it is explicitly listed in no_proxy. The symptom is brutal: both servers come +# up healthy, every probe is routed away from them, and after the startup +# timeout the launcher kills two working processes. +_LOOPBACK_OPENER = urllib.request.build_opener( + urllib.request.ProxyHandler({})) + + +class UIError(RuntimeError): + """A user-facing WebUI launcher error.""" + + +def _port_number(value: str) -> int: + """Parse a TCP port early enough for argparse to show a concise error.""" + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError('port must be an integer') from exc + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError('port must be between 1 and 65535') + return port + + def subparser_func(args): - """ Function which will be called for a specific sub parser. - """ + """Build the command object selected by argparse.""" return UICMD(args) class UICMD(CLICommand): - """The webui command class.""" + """Start FastAPI and the React Router development server together.""" name = 'ui' @@ -26,143 +78,721 @@ def __init__(self, args): @staticmethod def define_args(parsers: argparse.ArgumentParser): - """Define args for the ui command.""" + """Define ``ms-agent ui`` arguments.""" parser: argparse.ArgumentParser = parsers.add_parser(UICMD.name) parser.add_argument( '--host', type=str, - default='0.0.0.0', - help='The server host to bind to.') + default=DEFAULT_HOST, + help='Public frontend host (default: 127.0.0.1).') parser.add_argument( '--port', - type=int, - default=7860, - help='The server port to bind to.') + type=_port_number, + default=DEFAULT_PORT, + help='Public frontend port (default: 7860).') + parser.add_argument( + '--backend-port', + type=_port_number, + default=DEFAULT_BACKEND_PORT, + help='Internal FastAPI port (default: 8000).') parser.add_argument( '--reload', action='store_true', - help='Enable auto-reload for development.') + help='Reload the Python backend when its source changes.') + parser.add_argument( + '--skip-install', + action='store_true', + help='Do not install missing project-local dependencies.') parser.add_argument( '--production', action='store_true', - help='Run in production mode (serve built frontend).') + help='Reserved; production SSR is not supported by this launcher.') parser.add_argument( '--no-browser', action='store_true', - help='Do not automatically open browser.') + help='Do not automatically open the browser.') parser.set_defaults(func=subparser_func) def execute(self): - current_file = Path(__file__).resolve() - project_root = current_file.parent.parent.parent.parent - webui_dir = project_root / 'webui' - - if not webui_dir.exists(): - import ms_agent - ms_agent_path = Path(ms_agent.__file__).parent - webui_dir = ms_agent_path / 'webui' + processes: List[Tuple[str, subprocess.Popen]] = [] + exit_code = 0 + previous_signal_handlers = {} - if not webui_dir.exists(): - webui_dir = Path.cwd() / 'webui' + # A service manager normally stops the launcher with SIGTERM rather + # than Ctrl+C; a POSIX terminal may send SIGHUP when it closes. + # Translate both into the same graceful path so neither child process + # tree is left behind. + for signal_name in ('SIGTERM', 'SIGHUP'): + shutdown_signal = getattr(signal, signal_name, None) + if shutdown_signal is None: + continue + previous_signal_handlers[shutdown_signal] = signal.getsignal( + shutdown_signal) + signal.signal(shutdown_signal, _raise_keyboard_interrupt) - backend_dir = webui_dir / 'backend' - frontend_dir = webui_dir / 'frontend' + try: + if self.args.production: + raise UIError( + '--production is not supported by the source WebUI ' + 'launcher. Run without it for local development.') + if self.args.port == self.args.backend_port: + raise UIError( + 'The frontend and backend ports must be different.') - if not webui_dir.exists() or not backend_dir.exists(): - print('Error: WebUI directory not found.') - sys.exit(1) + frontend_host = _bind_host(self.args.host) + if not frontend_host: + raise UIError('The frontend host cannot be empty.') - frontend_dist = frontend_dir / 'dist' - frontend_built = frontend_dist.exists() and (frontend_dist - / 'index.html').exists() + webui_dir = _find_webui_dir() + backend_dir = webui_dir / 'backend' + frontend_dir = webui_dir / 'frontend' - if self.args.production and not frontend_built: - print( - 'Error: Frontend not built. Please run "npm run build" in webui/frontend first.' + tools = {'node': _require_executable('node')} + if not self.args.skip_install: + tools.update({ + 'uv': _require_executable('uv'), + 'pnpm': _require_executable('pnpm'), + }) + _check_tool_versions(tools, frontend_dir=frontend_dir) + # Claim both ports BEFORE mutating anything. Dependency sync takes + # seconds and writes to .venv / node_modules; discovering the port + # clash only after that (as a child's exit code) wasted the work and + # reported "backend exited unexpectedly" instead of naming the port. + _check_ports_available(frontend_host, self.args.port, + self.args.backend_port) + _ensure_dependencies( + backend_dir, + frontend_dir, + tools, + skip_install=self.args.skip_install, ) - sys.exit(1) - if not self.args.production and not frontend_built: - if self._build_frontend(frontend_dir): - frontend_built = True + backend_url = f'http://127.0.0.1:{self.args.backend_port}' + public_url = _public_url(frontend_host, self.args.port) - browser_host = 'localhost' if self.args.host == '0.0.0.0' else self.args.host - browser_url = f'http://{browser_host}:{self.args.port}' + print('MS-Agent WebUI - local development mode', flush=True) + print(f' Frontend: {public_url}', flush=True) + print(f' Backend: {backend_url}', flush=True) + if not _is_loopback_host(frontend_host): + print( + ' Warning: the frontend development server is exposed ' + 'beyond this machine.', + flush=True, + ) - backend_str = str(backend_dir) - if backend_str not in sys.path: - sys.path.insert(0, backend_str) + backend = _start_backend( + backend_dir, + port=self.args.backend_port, + reload=self.args.reload, + ) + processes.append(('backend', backend)) + _wait_for_http( + f'{backend_url}/api/health', + processes, + timeout=DEFAULT_STARTUP_TIMEOUT, + label='backend', + ) - original_argv = sys.argv - original_cwd = os.getcwd() - try: - os.chdir(backend_dir) - from main import main - - sys.argv = [ - 'main.py', - '--host', - self.args.host, - '--port', - str(self.args.port), - ] - if self.args.reload: - sys.argv.append('--reload') - - if not self.args.no_browser and frontend_built: - - def open_browser(): - time.sleep(1.5) - webbrowser.open(browser_url) - - browser_thread = threading.Thread( - target=open_browser, daemon=True) - browser_thread.start() - - main() + frontend = _start_frontend( + frontend_dir, + tools['node'], + host=frontend_host, + port=self.args.port, + backend_url=backend_url, + ) + processes.append(('frontend', frontend)) + _wait_for_http( + public_url, + processes, + timeout=DEFAULT_STARTUP_TIMEOUT, + label='frontend', + ) + + print(f'WebUI ready: {public_url}', flush=True) + if not self.args.no_browser: + try: + if not webbrowser.open(public_url): + print( + f'Could not open a browser automatically. Open ' + f'{public_url} manually.', + file=sys.stderr, + ) + except (webbrowser.Error, OSError) as exc: + print( + f'Could not open a browser automatically: {exc}. ' + f'Open {public_url} manually.', + file=sys.stderr, + ) + + _monitor_processes(processes) except KeyboardInterrupt: - print('\nShutting down...') - sys.exit(0) - except Exception as e: - print(f'Error starting WebUI: {e}') - import traceback - traceback.print_exc() - sys.exit(1) + print('\nShutting down WebUI...', flush=True) + except UIError as exc: + print(f'Error starting WebUI: {exc}', file=sys.stderr, flush=True) + exit_code = 1 finally: - sys.argv = original_argv - os.chdir(original_cwd) + for _name, process in reversed(processes): + _terminate_process_tree(process) + for shutdown_signal, previous in previous_signal_handlers.items(): + # getsignal() returns None when the handler was installed from + # C (an embedding host), and signal.signal(sig, None) raises + # TypeError — inside `finally` that would replace the real + # exception with a traceback about signal plumbing. + if previous is None: + continue + signal.signal(shutdown_signal, previous) + + if exit_code: + raise SystemExit(exit_code) + + +def _find_webui_dir() -> Path: + """Find a complete WebUI tree in source and future package layouts.""" + candidates = [Path(__file__).resolve().parents[2] / 'webui'] + + try: + import ms_agent + + candidates.append(Path(ms_agent.__file__).resolve().parent / 'webui') + except (ImportError, TypeError): + pass + + candidates.append(Path.cwd() / 'webui') + + checked: List[str] = [] + for candidate in candidates: + resolved = candidate.resolve() + marker_paths = ( + resolved / 'backend' / 'app' / 'main.py', + resolved / 'backend' / 'pyproject.toml', + resolved / 'frontend' / 'package.json', + resolved / 'frontend' / 'vite.config.ts', + ) + if all(path.is_file() for path in marker_paths): + return resolved + if str(resolved) not in checked: + checked.append(str(resolved)) + + raise UIError('WebUI source tree not found. Checked: ' + + ', '.join(checked)) + + +def _port_in_use(host: str, port: int) -> bool: + """Whether *host:port* is already bound (best effort, non-intrusive).""" + for family, socktype, proto, _canon, addr in socket.getaddrinfo( + host or '127.0.0.1', port, type=socket.SOCK_STREAM): + with socket.socket(family, socktype, proto) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + probe.bind(addr) + except OSError: + return True + return False + + +def _check_ports_available(frontend_host: str, frontend_port: int, + backend_port: int) -> None: + """Fail early, and name the port — the most common cause is a second run.""" + busy = [] + if _port_in_use(frontend_host, frontend_port): + busy.append(f'frontend {frontend_host}:{frontend_port}') + # The backend is always bound to loopback (see _start_backend). + if _port_in_use('127.0.0.1', backend_port): + busy.append(f'backend 127.0.0.1:{backend_port}') + if busy: + raise UIError( + 'Port already in use: ' + ', '.join(busy) + + '. Another "ms-agent ui" is probably still running — stop it, or ' + 'pass different --port / --backend-port values.') + + +def _require_executable(name: str) -> str: + """Resolve a required executable, including ``.cmd``/``.exe`` on Windows.""" + executable = shutil.which(name) + if executable: + return executable + + install_hints = { + 'uv': 'Install uv from https://docs.astral.sh/uv/.', + 'node': 'Install Node.js 22.22.0 or newer from https://nodejs.org/.', + 'pnpm': 'Install pnpm 10 (for example: corepack enable).', + } + raise UIError(f'Required command "{name}" was not found. ' + f'{install_hints.get(name, "Install it and retry.")}') + + +def _check_tool_versions(tools: Dict[str, str], + frontend_dir: Optional[Path] = None) -> None: + """Gate on tool versions, always naming the executable that was measured. + + Printing the resolved path matters more than the version: the common failure + is "I installed it into this environment but PATH resolved something else", + which an unadorned version number cannot distinguish. + """ + node_version = _read_semantic_version(tools['node'], '--version', 'Node.js') + if node_version < MIN_NODE_VERSION: + required = '.'.join(str(part) for part in MIN_NODE_VERSION) + actual = '.'.join(str(part) for part in node_version) + raise UIError( + f'Node.js {required} or newer is required by React Router 8 ' + f'(found {actual} at {tools["node"]}).') + + if 'uv' in tools: + uv_version = _read_semantic_version(tools['uv'], '--version', 'uv') + if uv_version < MIN_UV_VERSION: + required = '.'.join(str(part) for part in MIN_UV_VERSION) + actual = '.'.join(str(part) for part in uv_version) + raise UIError( + f'uv {required} or newer is required (found {actual} at ' + f'{tools["uv"]}). If you installed a newer uv into this ' + f'environment, an older one is still earlier on PATH — check ' + f'with "command -v uv".') + + if 'pnpm' in tools: + # Measure pnpm inside webui/frontend: `packageManager` in its + # package.json makes pnpm self-manage, so the binary that actually runs + # `pnpm install` there may differ from the one first on PATH. Probing + # from an arbitrary cwd validates the wrong executable. + pnpm_version = _read_semantic_version( + tools['pnpm'], '--version', 'pnpm', cwd=frontend_dir) + if pnpm_version[0] != 10: + actual = '.'.join(str(part) for part in pnpm_version) + raise UIError( + f'pnpm 10.x is required by this WebUI (found {actual} at ' + f'{tools["pnpm"]}). Install it with ' + f'"npm install --global --prefix \\"$CONDA_PREFIX\\" ' + f'pnpm@10.17.1" (or "corepack prepare pnpm@10.17.1 --activate" ' + f'on Node < 25, where corepack is still bundled), then verify ' + f'with "command -v pnpm".') + + +def _read_semantic_version(executable: str, + flag: str, + label: str, + cwd: Optional[Path] = None) -> Tuple[int, int, int]: + run_kwargs: Dict[str, Any] = {} + if cwd is not None: + run_kwargs['cwd'] = str(cwd) + if _requires_windows_shell(executable): + # Corepack commonly exposes pnpm as pnpm.cmd. CreateProcess cannot + # execute a command script directly, so let subprocess quote it for + # the native command processor. All arguments here are launcher-owned. + run_kwargs['shell'] = True + try: + result = subprocess.run( + [executable, flag], + capture_output=True, + check=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=10, + **run_kwargs, + ) + except (OSError, subprocess.CalledProcessError, + subprocess.TimeoutExpired) as exc: + raise UIError(f'Could not determine the {label} version: {exc}') from exc + + return _parse_semantic_version(result.stdout, label, executable) + + +#: A version at the very start of a line: ``v22.22.0`` / ``10.17.1``. +_VERSION_BARE = re.compile(r'^v?(\d+)\.(\d+)(?:\.(\d+))?\b') +#: A version after a single leading token: ``uv 0.12.1 (a6042f67 2026-03-24)``. +_VERSION_AFTER_NAME = re.compile(r'^\S+\s+v?(\d+)\.(\d+)(?:\.(\d+))?\b') + + +def _parse_semantic_version(output: str, label: str, + executable: str) -> Tuple[int, int, int]: + """Read the tool's version, ignoring any preamble noise. + + Searching the whole buffer for the first dotted number is wrong: tools + prepend notices (Node deprecation warnings, corepack "about to download + pnpm-10.17.1.tgz", mise/conda preambles, uv "a newer version is available", + and on Windows whatever cmd.exe AutoRun echoes). Matching that noise yields + a *confident wrong version* — worse than failing to parse, because the + caller then rejects a perfectly good toolchain citing a number the user + never installed. + + Two passes over the lines, last first: a bare version wins outright, and + only if no line carries one do we accept `` `` (uv's shape). + Ordering the passes this way keeps a trailing "Update available 11.0.0" + notice from beating the real version on the line above it. + """ + lines = [line.strip() for line in output.splitlines() if line.strip()] + for pattern in (_VERSION_BARE, _VERSION_AFTER_NAME): + for line in reversed(lines): + match = pattern.match(line) + if match: + return tuple(int(part or 0) for part in match.groups()) + raise UIError(f'Could not parse the {label} version from {executable}: ' + f'{output!r}') + + +def _ensure_dependencies( + backend_dir: Path, + frontend_dir: Path, + tools: Dict[str, str], + skip_install: bool, +) -> None: + """Synchronize local environments without changing either lockfile.""" + backend_missing = not (backend_dir / '.venv').is_dir() + frontend_missing = not (frontend_dir / 'node_modules').is_dir() + + missing = [] + if backend_missing: + missing.append('webui/backend/.venv') + if frontend_missing: + missing.append('webui/frontend/node_modules') + if missing and skip_install: + raise UIError( + 'Missing local dependencies: ' + ', '.join(missing) + + '. Remove --skip-install or install them manually.') + if skip_install: + return + + action = 'Installing' if backend_missing else 'Checking' + print(f'[setup] {action} WebUI backend dependencies...', flush=True) + backend_env = _child_environment() + backend_env['UV_PROJECT_ENVIRONMENT'] = str(backend_dir / '.venv') + # --locked, not --frozen: `--frozen` means "use the lockfile WITHOUT + # checking that it is up to date", which is the opposite of pnpm's + # identically-named --frozen-lockfile. Since `ms-agent` is a path dependency + # whose requirements are dynamic (setup.py parses requirements/*.txt), a + # stale lock would sync a venv missing a new dependency and only surface as + # an ImportError inside the worker, after the 120s health-check wait. + # --inexact: uv syncs exactly by default and would UNINSTALL anything not in + # the resolution — including the dev group this command excludes. Without it + # every launch removes pytest, so `webui/backend`'s own test suite cannot + # survive a single `ms-agent ui`. + _run_setup( + [tools['uv'], 'sync', '--locked', '--no-dev', '--inexact'], + cwd=backend_dir, + label='backend dependency synchronization', + env=backend_env, + ) + + action = 'Installing' if frontend_missing else 'Checking' + print(f'[setup] {action} WebUI frontend dependencies...', flush=True) + _run_setup( + [tools['pnpm'], 'install', '--frozen-lockfile'], + cwd=frontend_dir, + label='frontend dependency synchronization', + ) + + +def _run_setup(command: List[str], + cwd: Path, + label: str, + env: Optional[Dict[str, str]] = None) -> None: + process = _spawn( + command, + cwd=cwd, + env=env, + shell=_requires_windows_shell(command[0]), + ) + try: + return_code = process.wait() + except KeyboardInterrupt: + _terminate_process_tree(process) + raise + except OSError as exc: + # Reap before surfacing: this process was never added to `processes`, so + # execute()'s finally cannot reach it and it would outlive the launcher. + _terminate_process_tree(process) + raise UIError(f'{label} failed: {exc}') from exc + if return_code: + _terminate_process_tree(process) + raise UIError(f'{label} failed with exit code {return_code}.') + + +def _child_environment() -> Dict[str, str]: + env = os.environ.copy() + env.setdefault('PYTHONUTF8', '1') + env.setdefault('PYTHONIOENCODING', 'utf-8') + return env + + +def _requires_windows_shell(executable: str) -> bool: + """Return whether an executable is a Windows command script.""" + return IS_WINDOWS and Path(executable).suffix.lower() in {'.bat', '.cmd'} + + +def _raise_keyboard_interrupt(_signum, _frame) -> None: + """Route a termination signal through ``execute``'s cleanup block.""" + raise KeyboardInterrupt - def _build_frontend(self, frontend_dir: Path) -> bool: - import subprocess + +def _start_backend( + backend_dir: Path, + port: int, + reload: bool, +) -> subprocess.Popen: + env = _child_environment() + env.update({ + 'HOST': '127.0.0.1', + 'PORT': str(port), + }) + python_dir = 'Scripts' if IS_WINDOWS else 'bin' + python_name = 'python.exe' if IS_WINDOWS else 'python' + python = backend_dir / '.venv' / python_dir / python_name + if not python.is_file(): + raise UIError( + f'WebUI backend interpreter not found at {python}. ' + 'Run without --skip-install to repair the environment.') + + command = [ + str(python), + '-m', + 'uvicorn', + 'app.main:app', + '--host', + '127.0.0.1', + '--port', + str(port), + ] + if reload: + command.extend(['--reload', '--reload-dir', 'app']) + return _spawn( + command, + cwd=backend_dir, + env=env, + ) + + +def _start_frontend( + frontend_dir: Path, + node: str, + host: str, + port: int, + backend_url: str, +) -> subprocess.Popen: + env = _child_environment() + env['API_BASE_URL'] = backend_url + react_router = ( + frontend_dir / 'node_modules' / '@react-router' / 'dev' / 'bin.cjs') + if not react_router.is_file(): + raise UIError( + f'React Router CLI not found at {react_router}. ' + 'Run without --skip-install to repair the environment.') + return _spawn( + [ + node, + str(react_router), + 'dev', + '--host', + host, + '--port', + str(port), + '--strictPort', + ], + cwd=frontend_dir, + env=env, + ) + + +def _spawn(command: List[str], + cwd: Path, + env: Optional[Dict[str, str]], + shell: bool = False) -> subprocess.Popen: + kwargs = { + 'cwd': str(cwd), + 'env': env, + } + if shell: + kwargs['shell'] = True + if IS_WINDOWS: + kwargs['creationflags'] = CREATE_NEW_PROCESS_GROUP + else: + kwargs['start_new_session'] = True + + try: + return subprocess.Popen(command, **kwargs) + except OSError as exc: + raise UIError(f'Could not start {Path(command[0]).name}: {exc}') from exc + + +def _wait_for_http( + url: str, + processes: Iterable[Tuple[str, subprocess.Popen]], + timeout: float, + label: str, +) -> None: + deadline = time.monotonic() + timeout + last_error = 'not ready' + + while time.monotonic() < deadline: + for process_name, process in processes: + return_code = process.poll() + if return_code is not None: + raise UIError( + f'{process_name} exited before {label} was ready ' + f'(exit code {return_code}).') try: - subprocess.run(['npm', '--version'], - capture_output=True, - check=True, - timeout=5) - except (subprocess.TimeoutExpired, subprocess.CalledProcessError, - FileNotFoundError): - return False + request = urllib.request.Request( + url, headers={'User-Agent': 'ms-agent-ui-launcher'}) + with _LOOPBACK_OPENER.open(request, timeout=2) as response: + if response.status == 200: + return + last_error = f'HTTP {response.status}' + except urllib.error.HTTPError as exc: + last_error = f'HTTP {exc.code}' + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = str(exc) - node_modules = frontend_dir / 'node_modules' - if not node_modules.exists(): + time.sleep(0.25) + + raise UIError( + f'Timed out waiting for {label} at {url} ({last_error}).') + + +def _monitor_processes( + processes: Iterable[Tuple[str, subprocess.Popen]]) -> None: + while True: + for process_name, process in processes: + return_code = process.poll() + if return_code is not None: + raise UIError( + f'{process_name} exited unexpectedly ' + f'(exit code {return_code}).') + time.sleep(0.25) + + +def _terminate_process_tree(process: subprocess.Popen, + grace_seconds: float = 5.0) -> None: + """Stop a launcher child and its descendants cross-platform.""" + leader_running = process.poll() is None + + if IS_WINDOWS: + if not leader_running: + # Best effort: taskkill can still find the tree during the short + # interval before Windows finishes re-parenting descendants. + if not _taskkill(process.pid, force=True): + _warn_cleanup(process.pid) + return + try: + process.send_signal(CTRL_BREAK_EVENT) + except OSError: + if not _taskkill(process.pid, force=True): + _warn_cleanup(process.pid) + try: + process.wait(timeout=grace_seconds) + except (OSError, subprocess.TimeoutExpired): + _warn_cleanup(process.pid) + return + else: + # A process-group leader may already have exited while descendants are + # still alive. killpg remains valid in that state, so do not return + # merely because Popen.poll() has a return code. + try: + os.killpg(process.pid, signal.SIGTERM) + except (OSError, ProcessLookupError): + if leader_running: + try: + process.terminate() + except OSError: + pass + if not leader_running and _wait_for_posix_group_exit( + process.pid, grace_seconds): + return + if not leader_running: + try: + os.killpg(process.pid, signal.SIGKILL) + except (OSError, ProcessLookupError): + pass + if not _wait_for_posix_group_exit(process.pid, grace_seconds): + _warn_cleanup(process.pid) + return + + try: + process.wait(timeout=grace_seconds) + return + except (OSError, subprocess.TimeoutExpired): + pass + + if IS_WINDOWS: + if not _taskkill(process.pid, force=True): + _warn_cleanup(process.pid) + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except (OSError, ProcessLookupError): try: - subprocess.run(['npm', 'install'], - cwd=frontend_dir, - check=True, - timeout=300, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - except (subprocess.TimeoutExpired, subprocess.CalledProcessError): - return False + process.kill() + except OSError: + pass + try: + process.wait(timeout=grace_seconds) + except (OSError, subprocess.TimeoutExpired): + _warn_cleanup(process.pid) + + +def _taskkill(pid: int, force: bool) -> bool: + command = ['taskkill', '/PID', str(pid), '/T'] + if force: + command.append('/F') + try: + result = subprocess.run( + command, + capture_output=True, + timeout=10, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def _warn_cleanup(pid: int) -> None: + print( + f'Warning: could not confirm cleanup of process tree {pid}. ' + 'Check for remaining Python/Node processes.', + file=sys.stderr, + flush=True, + ) + + +def _wait_for_posix_group_exit(pid: int, timeout: float) -> bool: + """Wait until a POSIX process group no longer has live members.""" + deadline = time.monotonic() + timeout + while True: try: - subprocess.run(['npm', 'run', 'build'], - cwd=frontend_dir, - check=True, - timeout=300, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + os.killpg(pid, 0) + except ProcessLookupError: + return True + except PermissionError: + pass + except OSError: return True - except (subprocess.TimeoutExpired, subprocess.CalledProcessError): + if time.monotonic() >= deadline: return False + time.sleep(0.05) + + +def _bind_host(host: str) -> str: + """Normalize bracketed IPv6 URL literals for a server bind argument.""" + normalized = host.strip() + if normalized.startswith('[') and normalized.endswith(']'): + return normalized[1:-1] + return normalized + + +def _is_loopback_host(host: str) -> bool: + return host.strip().lower() in { + '127.0.0.1', + '::1', + '[::1]', + 'localhost', + } + + +def _public_url(host: str, port: int) -> str: + normalized = host.strip() + if normalized == '0.0.0.0': + normalized = '127.0.0.1' + elif normalized in {'::', '[::]'}: + normalized = '::1' + if ':' in normalized and not normalized.startswith('['): + normalized = f'[{normalized}]' + return f'http://{normalized}:{port}' diff --git a/projects/deep_research/v2/README.md b/projects/deep_research/v2/README.md index 331d833ae..73ce38cb0 100644 --- a/projects/deep_research/v2/README.md +++ b/projects/deep_research/v2/README.md @@ -220,20 +220,12 @@ DR_BENCH_ROOT=/path/to/deep_research_bench \ **Note:** The script automatically reads API keys from `.env` in the repository root. Ensure environment variables are properly configured before running. -#### Run in WebUI +#### WebUI status -You can also use Agentic Insight v2 from the built-in WebUI: - -```bash -ms-agent ui -``` - -Then open `http://localhost:7860`, select **Deep Research**, and make sure you have configured: - -- `OPENAI_API_KEY` / `OPENAI_BASE_URL` (LLM settings) -- Either `EXA_API_KEY` or `SERPAPI_API_KEY` (search tools) - -You can set them via `.env` or in WebUI **Settings**. WebUI run artifacts are stored under `webui/work_dir//`. +The current source-checkout WebUI is a general agent workspace and does not +expose the legacy dedicated **Deep Research** selector. Run Agentic Insight v2 +with the CLI workflow above. See the [WebUI guide](../../../webui/README.md) for +the capabilities and configuration of the current interface. ### Outputs (Where to Find Results) diff --git a/projects/deep_research/v2/README_zh.md b/projects/deep_research/v2/README_zh.md index e44a21a94..fbd8dbdf0 100644 --- a/projects/deep_research/v2/README_zh.md +++ b/projects/deep_research/v2/README_zh.md @@ -220,20 +220,11 @@ DR_BENCH_ROOT=/path/to/deep_research_bench \ **注意:** 脚本会从仓库根目录的 `.env` 自动读取 API keys,请确保已正确配置环境变量。 -#### 在 WebUI 中使用 +#### WebUI 状态 -你也可以在内置 WebUI 中使用 Agentic Insight v2: - -```bash -ms-agent ui -``` - -然后打开 `http://localhost:7860`,选择 **Deep Research**,并确保已配置: - -- `OPENAI_API_KEY` / `OPENAI_BASE_URL`(LLM 配置) -- 二选一:`EXA_API_KEY` 或 `SERPAPI_API_KEY`(搜索工具) - -你可以通过 `.env` 或 WebUI 的 **Settings** 进行配置。WebUI 的运行产物会保存在 `webui/work_dir//` 下。 +当前源码版 WebUI 是通用智能体工作台,不再提供旧版专用的 **Deep Research** +选择入口。请通过上文的 CLI 流程运行 Agentic Insight v2;当前界面的能力和配置 +方式见 [WebUI 完整指南](../../../webui/README_ZH.md)。 ### 输出(结果位置) diff --git a/requirements/webui.txt b/requirements/webui.txt deleted file mode 100644 index 34bb15d3f..000000000 --- a/requirements/webui.txt +++ /dev/null @@ -1,4 +0,0 @@ -aiohttp -fastapi -pandas -uvicorn[standard] diff --git a/setup.py b/setup.py index 0bb309db2..7b9a3a5dd 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,6 @@ import os import shutil -from typing import List def readme(): @@ -137,92 +136,11 @@ def run(self): shutil.copytree(src, dst) - # Build and copy webui - self._build_and_copy_webui() - def _build_and_copy_webui(self): - """Build frontend and copy webui files to build directory""" - import subprocess - - repo_root = os.path.dirname(__file__) - webui_src = os.path.join(repo_root, 'webui') - - if not os.path.isdir(webui_src): - print( - 'Warning: webui directory not found, skipping webui packaging') - return - - frontend_src = os.path.join(webui_src, 'frontend') - backend_src = os.path.join(webui_src, 'backend') - - # Check if npm is available - try: - subprocess.run(['npm', '--version'], - capture_output=True, - check=True, - timeout=5) - npm_available = True - except (subprocess.CalledProcessError, FileNotFoundError, - subprocess.TimeoutExpired): - npm_available = False - print( - 'Warning: npm not found, cannot build frontend. WebUI may not work properly.' - ) - - # Build frontend if npm is available - if npm_available and os.path.isdir(frontend_src): - print('Building frontend with npm...') - - # Install dependencies if needed - node_modules = os.path.join(frontend_src, 'node_modules') - if not os.path.exists(node_modules): - print('Installing frontend dependencies...') - try: - subprocess.run(['npm', 'install'], - cwd=frontend_src, - check=True, - timeout=300) - except (subprocess.CalledProcessError, - subprocess.TimeoutExpired) as e: - print(f'Warning: npm install failed: {e}') - return - - # Build frontend - try: - subprocess.run(['npm', 'run', 'build'], - cwd=frontend_src, - check=True, - timeout=300) - print('Frontend built successfully') - except (subprocess.CalledProcessError, - subprocess.TimeoutExpired) as e: - print(f'Warning: npm build failed: {e}') - return - - # Copy webui to build directory - webui_dst = os.path.join(self.build_lib, 'ms_agent', 'webui') - - # Copy backend - if os.path.isdir(backend_src): - backend_dst = os.path.join(webui_dst, 'backend') - if os.path.exists(backend_dst): - shutil.rmtree(backend_dst) - shutil.copytree(backend_src, backend_dst) - print(f'Copied backend to {backend_dst}') - - # Copy frontend dist (built files) - frontend_dist_src = os.path.join(frontend_src, 'dist') - if os.path.isdir(frontend_dist_src): - frontend_dst = os.path.join(webui_dst, 'frontend', 'dist') - os.makedirs(os.path.dirname(frontend_dst), exist_ok=True) - if os.path.exists(frontend_dst): - shutil.rmtree(frontend_dst) - shutil.copytree(frontend_dist_src, frontend_dst) - print(f'Copied frontend dist to {frontend_dst}') - else: - print( - 'Warning: frontend dist not found, WebUI may not work in production mode' - ) +# The SSR WebUI is intentionally source-checkout-only. ``ms-agent ui`` +# synchronizes its independent Python and Node lockfiles at runtime; building +# the framework package must not invoke a JavaScript package manager or copy an +# obsolete static ``dist`` tree. if __name__ == '__main__': @@ -237,7 +155,6 @@ def _build_and_copy_webui(self): extra_requires['research'], _ = parse_requirements( 'requirements/research.txt') extra_requires['code'], _ = parse_requirements('requirements/code.txt') - extra_requires['webui'], _ = parse_requirements('requirements/webui.txt') extra_requires['acp'], _ = parse_requirements('requirements/acp.txt') extra_requires['a2a'], _ = parse_requirements('requirements/a2a.txt') extra_requires['retrieval'], _ = parse_requirements( @@ -249,8 +166,7 @@ def _build_and_copy_webui(self): # yields a fully-featured install. ``docs`` is build-only and intentionally # excluded. De-duplicated for a clean, deterministic dependency set. all_requires = list(install_requires) - for _group in ('research', 'code', 'webui', 'acp', 'a2a', 'retrieval', - 'cinema'): + for _group in ('research', 'code', 'acp', 'a2a', 'retrieval', 'cinema'): all_requires.extend(extra_requires[_group]) extra_requires['all'] = sorted(set(all_requires)) @@ -271,8 +187,6 @@ def _build_and_copy_webui(self): package_data={ 'ms_agent': [ 'projects/**/*', - 'webui/backend/**/*', - 'webui/frontend/dist/**/*', ], '': ['*.h', '*.cpp', '*.cu'], }, diff --git a/tests/cli/test_ui.py b/tests/cli/test_ui.py new file mode 100644 index 000000000..83b2d8fe5 --- /dev/null +++ b/tests/cli/test_ui.py @@ -0,0 +1,658 @@ +import signal +import subprocess +from argparse import ArgumentParser +from types import SimpleNamespace + +import pytest + +from ms_agent.cli import ui + + +def _parse_ui_args(*extra_args): + parser = ArgumentParser() + subparsers = parser.add_subparsers() + ui.UICMD.define_args(subparsers) + return parser.parse_args(['ui'] + list(extra_args)) + + +def test_ui_parser_defaults_are_local_and_non_production(): + args = _parse_ui_args() + + assert args.host == '127.0.0.1' + assert args.port == 7860 + assert args.backend_port == 8000 + assert args.reload is False + assert args.skip_install is False + assert args.production is False + assert args.no_browser is False + + +@pytest.mark.parametrize('value', ['0', '-1', '65536', 'not-a-port']) +def test_ui_parser_rejects_invalid_ports(value): + with pytest.raises(SystemExit): + _parse_ui_args('--port', value) + + +@pytest.mark.parametrize( + ('host', 'port', 'expected'), + [ + ('127.0.0.1', 7860, 'http://127.0.0.1:7860'), + ('0.0.0.0', 9000, 'http://127.0.0.1:9000'), + ('::', 7860, 'http://[::1]:7860'), + ('[::]', 7860, 'http://[::1]:7860'), + ('::1', 7860, 'http://[::1]:7860'), + ('2001:db8::1', 8080, 'http://[2001:db8::1]:8080'), + (' localhost ', 7000, 'http://localhost:7000'), + ], +) +def test_public_url_is_browser_safe(host, port, expected): + assert ui._public_url(host, port) == expected + + +@pytest.mark.parametrize( + ('host', 'expected'), + [ + ('127.0.0.1', True), + ('LOCALHOST', True), + ('[::1]', True), + ('0.0.0.0', False), + ('192.168.1.20', False), + ], +) +def test_loopback_host_detection(host, expected): + assert ui._is_loopback_host(host) is expected + + +@pytest.mark.parametrize( + ('host', 'expected'), + [('[::1]', '::1'), ('[::]', '::'), (' localhost ', 'localhost')], +) +def test_bind_host_removes_url_only_ipv6_brackets(host, expected): + assert ui._bind_host(host) == expected + + +def test_read_semantic_version_accepts_node_prefix(monkeypatch): + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(stdout='v22.22.0\n') + + monkeypatch.setattr(ui.subprocess, 'run', fake_run) + + assert ui._read_semantic_version('/tools/node', '--version', + 'Node.js') == (22, 22, 0) + assert calls == [ + ( + ['/tools/node', '--version'], + { + 'capture_output': True, + 'check': True, + 'text': True, + 'encoding': 'utf-8', + 'errors': 'replace', + 'timeout': 10, + }, + ) + ] + + +def test_read_semantic_version_rejects_unparseable_output(monkeypatch): + monkeypatch.setattr( + ui.subprocess, + 'run', + lambda *args, **kwargs: SimpleNamespace(stdout='not-a-version\n'), + ) + + with pytest.raises(ui.UIError, match='Could not parse the pnpm version'): + ui._read_semantic_version('pnpm', '--version', 'pnpm') + + +def test_windows_command_script_uses_native_shell_for_version(monkeypatch): + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(stdout='10.17.1\n') + + monkeypatch.setattr(ui, 'IS_WINDOWS', True) + monkeypatch.setattr(ui.subprocess, 'run', fake_run) + + assert ui._read_semantic_version( + r'C:\Program Files\nodejs\pnpm.cmd', '--version', 'pnpm') == ( + 10, 17, 1) + assert calls[0][1]['shell'] is True + + +def test_windows_command_script_uses_native_shell_for_setup( + monkeypatch, + tmp_path, +): + calls = [] + monkeypatch.setattr(ui, 'IS_WINDOWS', True) + + class FinishedProcess: + + @staticmethod + def wait(): + return 0 + + monkeypatch.setattr( + ui, + '_spawn', + lambda command, **kwargs: calls.append((command, kwargs)) + or FinishedProcess(), + ) + + ui._run_setup( + [r'C:\Program Files\nodejs\pnpm.cmd', 'install'], + cwd=tmp_path, + label='frontend setup', + ) + + assert calls == [ + ( + [r'C:\Program Files\nodejs\pnpm.cmd', 'install'], + { + 'cwd': tmp_path, + 'env': None, + 'shell': True, + }, + ) + ] + + +@pytest.mark.parametrize( + ('wait_result', 'raised'), + [('interrupt', KeyboardInterrupt), (7, ui.UIError)], +) +def test_setup_failure_cleans_its_process_tree( + monkeypatch, + tmp_path, + wait_result, + raised, +): + process = SimpleNamespace(pid=1234) + + def wait(): + if wait_result == 'interrupt': + raise KeyboardInterrupt + return wait_result + + process.wait = wait + cleanup_calls = [] + monkeypatch.setattr(ui, '_spawn', lambda *args, **kwargs: process) + monkeypatch.setattr( + ui, + '_terminate_process_tree', + lambda child: cleanup_calls.append(child), + ) + + with pytest.raises(raised): + ui._run_setup(['tool', 'sync'], tmp_path, 'dependency setup') + + assert cleanup_calls == [process] + + +def test_tool_versions_accept_supported_node_and_pnpm(monkeypatch, tmp_path): + versions = { + '/tools/node': (22, 22, 0), + '/tools/pnpm': (10, 17, 1), + } + probes = [] + + def fake_version(executable, flag, label, cwd=None): + probes.append({'executable': executable, 'label': label, 'cwd': cwd}) + return versions[executable] + + monkeypatch.setattr(ui, '_read_semantic_version', fake_version) + + ui._check_tool_versions( + { + 'node': '/tools/node', + 'pnpm': '/tools/pnpm', + }, + frontend_dir=tmp_path, + ) + + # Assert what was measured, not merely that nothing raised: an empty body in + # _check_tool_versions used to satisfy this test. + assert [p['label'] for p in probes] == ['Node.js', 'pnpm'] + # pnpm MUST be probed inside webui/frontend — `packageManager` there makes + # pnpm self-manage, so a probe from another cwd measures the wrong binary. + assert probes[1]['cwd'] == tmp_path + assert probes[0]['cwd'] is None + + +def test_tool_versions_do_not_require_pnpm_when_install_is_skipped(monkeypatch): + calls = [] + monkeypatch.setattr( + ui, + '_read_semantic_version', + lambda executable, flag, label, cwd=None: calls.append(label) or + (22, 22, 0), + ) + + ui._check_tool_versions({'node': '/tools/node'}) + + assert calls == ['Node.js'] + + +@pytest.mark.parametrize( + ('node_version', 'pnpm_version', 'message'), + [ + ((22, 21, 9), (10, 17, 1), 'Node.js 22.22.0 or newer'), + ((22, 22, 0), (9, 15, 0), 'pnpm 10.x is required'), + ((24, 0, 0), (11, 0, 0), 'pnpm 10.x is required'), + ], +) +def test_tool_versions_reject_unsupported_versions( + monkeypatch, + node_version, + pnpm_version, + message, +): + versions = { + 'node': node_version, + 'pnpm': pnpm_version, + } + monkeypatch.setattr( + ui, + '_read_semantic_version', + lambda executable, flag, label, cwd=None: versions[executable], + ) + + with pytest.raises(ui.UIError, match=message): + ui._check_tool_versions({'node': 'node', 'pnpm': 'pnpm'}) + + +def test_dependency_sync_uses_locked_project_local_commands( + tmp_path, + monkeypatch, +): + backend_dir = tmp_path / 'webui' / 'backend' + frontend_dir = tmp_path / 'webui' / 'frontend' + backend_dir.mkdir(parents=True) + frontend_dir.mkdir(parents=True) + calls = [] + + monkeypatch.setattr(ui, '_child_environment', lambda: {'BASE': 'value'}) + + def fake_run_setup(command, cwd, label, env=None): + calls.append({ + 'command': command, + 'cwd': cwd, + 'label': label, + 'env': None if env is None else env.copy(), + }) + + monkeypatch.setattr(ui, '_run_setup', fake_run_setup) + + ui._ensure_dependencies( + backend_dir, + frontend_dir, + { + 'uv': '/tools/uv', + 'pnpm': '/tools/pnpm', + }, + skip_install=False, + ) + + assert calls == [ + { + 'command': [ + '/tools/uv', 'sync', '--locked', '--no-dev', '--inexact' + ], + 'cwd': backend_dir, + 'label': 'backend dependency synchronization', + 'env': { + 'BASE': 'value', + 'UV_PROJECT_ENVIRONMENT': str(backend_dir / '.venv'), + }, + }, + { + 'command': [ + '/tools/pnpm', + 'install', + '--frozen-lockfile', + ], + 'cwd': frontend_dir, + 'label': 'frontend dependency synchronization', + 'env': None, + }, + ] + + +def test_skip_install_reports_all_missing_local_dependencies( + tmp_path, + monkeypatch, +): + backend_dir = tmp_path / 'backend' + frontend_dir = tmp_path / 'frontend' + backend_dir.mkdir() + frontend_dir.mkdir() + monkeypatch.setattr( + ui, + '_run_setup', + lambda *args, **kwargs: pytest.fail('setup must not run'), + ) + + with pytest.raises(ui.UIError) as error: + ui._ensure_dependencies( + backend_dir, + frontend_dir, + { + 'uv': 'uv', + 'pnpm': 'pnpm', + }, + skip_install=True, + ) + + message = str(error.value) + assert 'webui/backend/.venv' in message + assert 'webui/frontend/node_modules' in message + + +@pytest.mark.parametrize('is_windows', [False, True]) +def test_spawn_uses_platform_process_group(monkeypatch, tmp_path, is_windows): + calls = [] + process = object() + + def fake_popen(command, **kwargs): + calls.append((command, kwargs)) + return process + + monkeypatch.setattr(ui, 'IS_WINDOWS', is_windows) + monkeypatch.setattr(ui.subprocess, 'Popen', fake_popen) + env = {'KEY': 'value'} + + result = ui._spawn(['tool', '--flag'], cwd=tmp_path, env=env) + + expected_kwargs = { + 'cwd': str(tmp_path), + 'env': env, + } + if is_windows: + expected_kwargs['creationflags'] = ui.CREATE_NEW_PROCESS_GROUP + else: + expected_kwargs['start_new_session'] = True + assert result is process + assert calls == [(['tool', '--flag'], expected_kwargs)] + + +class _FakeProcess: + + def __init__(self, pid, wait_results, poll_result=None): + self.pid = pid + self._wait_results = list(wait_results) + self.poll_result = poll_result + self.sent_signals = [] + self.wait_timeouts = [] + self.terminate_calls = 0 + self.kill_calls = 0 + + def poll(self): + return self.poll_result + + def send_signal(self, event): + self.sent_signals.append(event) + + def terminate(self): + self.terminate_calls += 1 + + def kill(self): + self.kill_calls += 1 + + def wait(self, timeout): + self.wait_timeouts.append(timeout) + result = self._wait_results.pop(0) + if result == 'timeout': + raise subprocess.TimeoutExpired('child', timeout) + return result + + +def test_windows_process_tree_cleanup_escalates_to_taskkill(monkeypatch): + process = _FakeProcess(4321, ['timeout', 0]) + taskkill_calls = [] + monkeypatch.setattr(ui, 'IS_WINDOWS', True) + monkeypatch.setattr( + ui, + '_taskkill', + lambda pid, force: taskkill_calls.append((pid, force)) or True, + ) + + ui._terminate_process_tree(process, grace_seconds=0.25) + + assert process.sent_signals == [ui.CTRL_BREAK_EVENT] + assert process.wait_timeouts == [0.25, 0.25] + assert taskkill_calls == [(4321, True)] + assert process.terminate_calls == 0 + assert process.kill_calls == 0 + + +def test_posix_cleanup_targets_group_after_leader_already_exited(monkeypatch): + process = _FakeProcess(7654, [], poll_result=1) + killpg_calls = [] + wait_calls = [] + monkeypatch.setattr(ui, 'IS_WINDOWS', False) + monkeypatch.setattr( + ui.os, + 'killpg', + lambda pid, event: killpg_calls.append((pid, event)), + raising=False, + ) + monkeypatch.setattr( + ui, + '_wait_for_posix_group_exit', + lambda pid, timeout: wait_calls.append((pid, timeout)) or True, + ) + + ui._terminate_process_tree(process) + + assert killpg_calls == [(7654, signal.SIGTERM)] + assert wait_calls == [(7654, 5.0)] + assert process.wait_timeouts == [] + + +def test_posix_cleanup_force_kills_stubborn_orphan_group(monkeypatch): + process = _FakeProcess(7755, [], poll_result=1) + killpg_calls = [] + wait_results = iter((False, True)) + monkeypatch.setattr(ui, 'IS_WINDOWS', False) + monkeypatch.setattr( + ui.os, + 'killpg', + lambda pid, event: killpg_calls.append((pid, event)), + raising=False, + ) + monkeypatch.setattr( + ui, + '_wait_for_posix_group_exit', + lambda pid, timeout: next(wait_results), + ) + + ui._terminate_process_tree(process, grace_seconds=0.1) + + assert killpg_calls == [ + (7755, signal.SIGTERM), + (7755, signal.SIGKILL), + ] + + +def test_windows_cleanup_attempts_tree_kill_after_leader_exited(monkeypatch): + process = _FakeProcess(8765, [], poll_result=1) + taskkill_calls = [] + warning_calls = [] + monkeypatch.setattr(ui, 'IS_WINDOWS', True) + monkeypatch.setattr( + ui, + '_taskkill', + lambda pid, force: taskkill_calls.append((pid, force)) or False, + ) + monkeypatch.setattr(ui, '_warn_cleanup', warning_calls.append) + + ui._terminate_process_tree(process) + + assert taskkill_calls == [(8765, True)] + assert warning_calls == [8765] + assert process.wait_timeouts == [] + + +def test_posix_process_tree_cleanup_escalates_process_group(monkeypatch): + process = _FakeProcess(9876, ['timeout', 0]) + killpg_calls = [] + sigkill = getattr(signal, 'SIGKILL', 9) + monkeypatch.setattr(ui, 'IS_WINDOWS', False) + monkeypatch.setattr(ui.signal, 'SIGKILL', sigkill, raising=False) + monkeypatch.setattr( + ui.os, + 'killpg', + lambda pid, event: killpg_calls.append((pid, event)), + raising=False, + ) + + ui._terminate_process_tree(process, grace_seconds=0.5) + + assert killpg_calls == [ + (9876, signal.SIGTERM), + (9876, sigkill), + ] + assert process.wait_timeouts == [0.5, 0.5] + assert process.sent_signals == [] + assert process.terminate_calls == 0 + assert process.kill_calls == 0 + + +# --- version parsing must ignore preamble noise --------------------------- + + +@pytest.mark.parametrize( + ('stdout', 'expected'), + [ + ('v22.22.0\n', (22, 22, 0)), + ('10.17.1\n', (10, 17, 1)), + ('22.22\n', (22, 22, 0)), + # Node prints deprecation notices; the old first-match-anywhere regex + # read "20.1" here and rejected a valid pnpm as "found 20.1.0". + ('WARN Node.js 20.1 is deprecated\n10.17.1\n', (10, 17, 1)), + # Corepack announces the download of the pinned pnpm before printing it. + ('! Corepack is about to download pnpm-10.17.1.tgz\n10.17.1\n', + (10, 17, 1)), + # uv tells you a newer version exists; that is not the version you have. + ('warning: uv 0.12.9 is available (you have 0.12.1)\nuv 0.12.1\n', + (0, 12, 1)), + # A version-manager / conda preamble with a dotted number. + ('Anaconda3 2024.02 activated\n10.17.1\n', (10, 17, 1)), + (' \n\nv26.0.0\n \n', (26, 0, 0)), + ], +) +def test_semantic_version_ignores_preamble_noise(stdout, expected): + assert ui._parse_semantic_version(stdout, 'pnpm', '/tools/pnpm') == expected + + +def test_semantic_version_error_names_the_executable(): + with pytest.raises(ui.UIError, match=r'/tools/pnpm'): + ui._parse_semantic_version('no version here\n', 'pnpm', '/tools/pnpm') + + +# --- port preflight ------------------------------------------------------- + + +def test_port_preflight_names_the_busy_port(monkeypatch): + monkeypatch.setattr(ui, '_port_in_use', + lambda host, port: port == 8000) + + with pytest.raises(ui.UIError) as excinfo: + ui._check_ports_available('127.0.0.1', 7860, 8000) + + message = str(excinfo.value) + assert 'backend 127.0.0.1:8000' in message + # The overwhelmingly common cause deserves to be named. + assert 'ms-agent ui' in message + assert 'frontend' not in message + + +def test_port_preflight_passes_when_both_free(monkeypatch): + monkeypatch.setattr(ui, '_port_in_use', lambda host, port: False) + ui._check_ports_available('127.0.0.1', 7860, 8000) + + +def test_port_in_use_detects_a_real_listener(): + import socket as _socket + with _socket.socket() as server: + server.bind(('127.0.0.1', 0)) + server.listen(1) + port = server.getsockname()[1] + assert ui._port_in_use('127.0.0.1', port) is True + # Once closed the port is free again (SO_REUSEADDR keeps this deterministic). + assert ui._port_in_use('127.0.0.1', port) is False + + +# --- health probe must never traverse a proxy ------------------------------ + + +def test_health_probe_opener_has_no_proxy_handler(): + import urllib.request as _rq + proxy_handlers = [ + h for h in ui._LOOPBACK_OPENER.handlers + if isinstance(h, _rq.ProxyHandler) + ] + # A ProxyHandler built from an empty mapping is present but inert; what must + # never happen is inheriting getproxies() (env or macOS System Config), which + # would route 127.0.0.1 away from our own servers and time the launcher out. + assert all(h.proxies == {} for h in proxy_handlers) + + +# --- version constants must not drift from the frontend manifest ----------- + + +def _frontend_package_json(): + import json + from pathlib import Path + root = Path(__file__).resolve().parents[2] + return json.loads( + (root / 'webui' / 'frontend' / 'package.json').read_text('utf-8')) + + +def test_min_node_version_matches_package_json_engines(): + """ui.MIN_NODE_VERSION duplicates engines.node by necessity (the launcher + gates before any Node tooling can read the manifest). This lock is what + keeps the two from drifting apart silently.""" + import re + engines = _frontend_package_json()['engines']['node'] + match = re.fullmatch(r'>=(\d+)\.(\d+)\.(\d+)', engines) + assert match, f'unexpected engines.node format: {engines!r}' + assert tuple(int(p) for p in match.groups()) == ui.MIN_NODE_VERSION + + +def test_pnpm_major_gate_matches_package_manager_pin(): + """The launcher accepts any pnpm 10.x; the manifest pins 10.17.1 and bounds + engines.pnpm to >=10 <11. All three must agree on the major.""" + pkg = _frontend_package_json() + pinned = pkg['packageManager'] + assert pinned.startswith('pnpm@10.'), pinned + assert pkg['engines']['pnpm'] == '>=10 <11' + + +def test_tool_versions_reject_old_uv_with_path(monkeypatch): + versions = { + '/tools/node': (22, 22, 0), + '/tools/uv': (0, 4, 9), + } + monkeypatch.setattr( + ui, + '_read_semantic_version', + lambda executable, flag, label, cwd=None: versions[executable], + ) + + with pytest.raises(ui.UIError) as excinfo: + ui._check_tool_versions({ + 'node': '/tools/node', + 'uv': '/tools/uv', + }) + + message = str(excinfo.value) + assert 'uv 0.5.0 or newer' in message + # The resolved path is the actionable part: "installed it, but PATH found + # another one" is indistinguishable from a bare version number. + assert '/tools/uv' in message diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 000000000..320322508 --- /dev/null +++ b/webui/README.md @@ -0,0 +1,406 @@ +# MS-Agent WebUI + +[中文说明](./README_ZH.md) + +This directory contains the source-checkout WebUI for MS-Agent: + +- `frontend/`: React Router 8 with server-side rendering, React 19, Vite, and + Ant Design. +- `backend/`: FastAPI, the MS-Agent SDK adapter, and SSE chat streaming. + +The supported launcher is intended for a local developer workspace. One +`ms-agent ui` command supervises two child services: + +```text +http://127.0.0.1:7860 React Router development server + /api/* ────────> FastAPI on http://127.0.0.1:8000 +``` + +This is not a production deployment or a standalone wheel installation. The +command needs an MS-Agent source checkout containing this `webui/` directory, +and the frontend is served by its development server. + +### Not carried over from the previous WebUI + +This interface replaced an earlier Vite/MUI one. It is a general agent +workspace and deliberately does **not** reproduce that version's dedicated +**Deep Research** view (the `deep_research_worker` / `DeepResearchView` +pipeline). Run Agentic Insight v2 from the CLI instead — see +[`projects/deep_research/v2`](../projects/deep_research/v2/README.md). + +### Runtime constraints + +- The chat runtime, event buffers, turn locks, and permission futures all live + in process memory, so the backend **must stay single-worker**. Adding uvicorn + workers silently breaks stop/interrupt, re-attach, and authorization prompts. +- Chat streams over SSE; there is no WebSocket anywhere in the stack. +- `--host` accepts any interface, and the stack has **no authentication**. On a + non-loopback host, anyone who can reach the port gets the agent — including + its shell tool. + +## Prerequisites + +| Tool | Required version | Purpose | +| --- | --- | --- | +| Python | 3.12 or newer | WebUI backend; `uv` creates its isolated environment | +| [uv](https://docs.astral.sh/uv/) | Recent version | Synchronizes `webui/backend/.venv` | +| [Node.js](https://nodejs.org/) | **22.22.0 or newer** | Required by React Router 8 | +| [pnpm](https://pnpm.io/installation) | **10.x** | Synchronizes frontend dependencies; the project pins 10.17.1 | + +With `--skip-install` only Node.js is required — the launcher never resolves +`uv` or `pnpm` in that mode. + +### Installing the tools without Corepack + +Corepack is no longer bundled with Node.js 25+, and inside a conda environment +"installed" is not the same as "resolved" — PATH may still find an older global +copy. The launcher prints the executable path it resolved whenever a version +check fails; to install both tools into the ACTIVE environment: + +```bash +pip install uv # uv into this env's bin/ +npm install --global --prefix "$CONDA_PREFIX" pnpm@10.17.1 +hash -r # rehash, then verify: +command -v uv pnpm # both under $CONDA_PREFIX/bin +``` + +On Node.js < 25, `corepack enable && corepack prepare pnpm@10.17.1 --activate` +still works as an alternative for pnpm. + +The WebUI's Python 3.12 does not need to be the currently activated Python; +uv selects a compatible interpreter and can download one when necessary. + +Check the tools before starting: + +```bash +python --version +uv --version +node --version +pnpm --version +``` + +If Corepack is available, the pinned pnpm release can be activated with: + +```bash +corepack enable +corepack prepare pnpm@10.17.1 --activate +``` + +## Quick start + +Run these commands from the MS-Agent repository root: + +```bash +pip install -e . +ms-agent ui +``` + +On the first launch, the command automatically runs the equivalent of: + +```bash +cd webui/backend && uv sync --locked --no-dev --inexact +cd webui/frontend && pnpm install --frozen-lockfile +``` + +Later launches recheck both environments against their lockfiles. No global +Python or Node packages are installed by this synchronization. After both +services report ready, the browser opens at . + +On Windows, use the PowerShell wrapper (it forces UTF-8 console output before +delegating to the same command): + +```powershell +py -m pip install -e . +.\webui\scripts\start-webui.ps1 +``` + +Press `Ctrl+C` in the launcher terminal to stop both services. + +## Configure a model + +Environment variables are not required to open the WebUI. The simplest setup +for real chat is through the browser: + +1. Start `ms-agent ui`. +2. Open **Settings → Models**. +3. Select a built-in provider, or add a compatible custom provider. +4. Configure its API key and base URL if required. +5. Add a model to that provider. +6. Select the default provider and model. + +The settings are shared with the normal MS-Agent CLI/TUI under +`~/.ms_agent` unless `MS_AGENT_HOME` is explicitly changed. Provider +credentials stored through the UI are written to `settings.json` in that +directory in plaintext; do not publish or commit that file. + +## Configuration files and environment variables + +The backend reads dotenv files from broadest to most specific: + +```text +/.env +/webui/.env +/webui/backend/.env +``` + +The effective precedence is: + +```text +process environment / launcher injection + > webui/backend/.env + > webui/.env + > repository .env +``` + +Real process environment variables are never overwritten by dotenv files. +This also makes arbitrary variables available to MCP `${NAME}` placeholders. +All `.env` files are ignored by Git. + +For an advanced or scripted setup, copy the template: + +```bash +cp webui/backend/.env.example webui/backend/.env +``` + +PowerShell equivalent: + +```powershell +Copy-Item .\webui\backend\.env.example .\webui\backend\.env +``` + +### Model bootstrap variables + +These are optional alternatives to configuring the model in the browser: + +| Variable | Meaning | +| --- | --- | +| `MS_AGENT_LLM_MODEL` | Model ID to seed. **Bootstrap does nothing at all unless this is set** — the other three are ignored without it. | +| `MS_AGENT_LLM_PROVIDER` | MS-Agent provider ID to seed (default `openai`). Must actually serve the model above: `qwen*` is DashScope/ModelScope, not OpenAI. | +| `OPENAI_API_KEY` | Credential, applied **only** when the provider is `openai`. Other providers resolve their own variable (`DASHSCOPE_API_KEY`, `DEEPSEEK_API_KEY`, …). | +| `OPENAI_BASE_URL` | Base URL, same `openai`-only rule. | + +Bootstrap only fills a missing `llm` block. If +`~/.ms_agent/settings.json` (or the selected `MS_AGENT_HOME`) already contains +`llm`, changing these variables does **not** replace it. Update the provider or +model in **Settings → Models** instead. + +### Optional runtime variables + +| Variable | Meaning | +| --- | --- | +| `MS_AGENT_HOME` | Override the SDK data directory; default is `~/.ms_agent` | +| `EXA_API_KEY` | Optional credential for Exa-backed web search | +| Any `${NAME}` variable | Expanded at runtime in MCP configuration | + +### Launcher-managed variables + +Normal `ms-agent ui` users should not set these manually: + +| Variable | How it is managed | +| --- | --- | +| `HOST`, `PORT` | Internal FastAPI address, derived from launcher options | +| `API_BASE_URL` | Injected into the React Router process | +| `CORS_ORIGINS` | Only normally relevant when starting the services manually | + +## Command-line options + +| Option | Default | Description | +| --- | --- | --- | +| `--host HOST` | `127.0.0.1` | Frontend listen address | +| `--port PORT` | `7860` | Frontend port and browser URL | +| `--backend-port PORT` | `8000` | Internal FastAPI port | +| `--reload` | off | Reload the Python backend after source changes; frontend HMR is always active | +| `--skip-install` | off | Skip both dependency synchronization commands; fails if either local environment is missing. With it, only Node.js needs to be on `PATH` — `uv` and `pnpm` are not resolved at all. | +| `--no-browser` | off | Do not open a browser automatically | +| `--production` | unsupported | Reserved option that exits with an explanatory error | + +Examples: + +```bash +# Use different ports +ms-agent ui --port 8080 --backend-port 8001 + +# Reload the backend as its source changes +ms-agent ui --reload + +# Start without opening a browser +ms-agent ui --no-browser + +# Deliberately expose the frontend to the local network +ms-agent ui --host 0.0.0.0 +``` + +The backend remains bound to `127.0.0.1`; browser API traffic goes through the +frontend proxy. Exposing the development server is not a production deployment +and does not add authentication or production hardening. + +## Start the two services manually + +Manual mode is useful when debugging the frontend and backend in separate +terminals. It is not needed for normal use. + +### 1. Backend + +Model credentials come from `webui/backend/.env` (see `.env.example`); copy it +before the first manual start. + +```bash +cd webui/backend +uv sync --locked +uv run --frozen dev +``` + +The backend listens on ; its health endpoint is +. The backend dependency points to the +containing MS-Agent checkout as an editable package. + +### 2. Frontend + +In another terminal: + +```bash +cd webui/frontend +pnpm install --frozen-lockfile +pnpm dev +``` + +Open . The Vite development server proxies `/api/*` to +`http://127.0.0.1:8000`, which is also the default endpoint used by SSR route +loaders. To use another backend port, set `API_BASE_URL` for the frontend +process before starting it. + +## Tests + +The backend suite lives in `webui/backend/tests` and runs inside the backend's +own environment. Note that the launcher syncs that environment **without** the +dev group, so install it once before testing: + +```bash +cd webui/backend +uv sync --locked # includes the dev group (pytest) +./.venv/bin/python -m pytest +``` + +The launcher/contract suites live with the repository's tests and run with any +Python that has the SDK installed: + +```bash +python -m pytest tests/cli/test_ui.py tests/ui +``` + +The frontend has no automated tests yet; `pnpm typecheck` is the gate, and +manual Chrome walkthroughs are the UI regression instrument. + +The repository CI (`pytest tests`) does **not** include `webui/backend/tests` — +its dependencies (FastAPI and friends) are not installed there. Run it locally +as above when touching the backend or the SDK surfaces it consumes. + +## Windows + +PowerShell is recommended. From the repository root, use the included UTF-8 +wrapper: + +```powershell +.\webui\scripts\start-webui.ps1 +``` + +All launcher arguments are forwarded: + +```powershell +.\webui\scripts\start-webui.ps1 --reload --no-browser +``` + +The wrapper switches the current console to UTF-8 and sets `PYTHONUTF8` and +`PYTHONIOENCODING`, preserving the fix introduced after Windows users reported +garbled output. + +If the local PowerShell execution policy blocks the script, allow it for the +current process only: + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\webui\scripts\start-webui.ps1 +``` + +This does not change the machine-wide or user-wide policy. The launcher uses a +Windows process group and stops descendant Python/Node processes when you press +`Ctrl+C`. The built-in terminal uses the native Windows command processor and +does not require a separate POSIX `sh`. Repository paths containing spaces and +non-ASCII characters are supported; keep the repository on a local filesystem +for the best file-watcher behavior. + +Useful Windows checks: + +```powershell +Get-Command ms-agent, uv, node, pnpm +node --version +pnpm --version +``` + +## Troubleshooting + +### A required command was not found + +Install the missing tool, reopen the terminal so `PATH` is refreshed, and run +the version checks above. The launcher rejects Node older than 22.22.0 and pnpm +outside the 10.x series before installing dependencies. + +### Dependency synchronization failed + +The first launch downloads both Python and Node dependencies and can take a +while. Check registry/network access, then run `ms-agent ui` again. To see the +failing operation independently, run the two synchronization commands shown in +the manual-start section. `--skip-install` is only appropriate after both +`webui/backend/.venv` and `webui/frontend/node_modules` already exist. + +### A port is already in use + +The launcher checks both ports before it touches any dependency, so this fails +fast and names the port. Two rules it enforces: + +- `--port` and `--backend-port` must differ. +- The frontend uses `--strictPort`, so a busy frontend port is a hard failure — + it never silently moves to the next one. + +Select both ports explicitly: + +```bash +ms-agent ui --port 8080 --backend-port 8001 +``` + +On Windows, inspect the defaults with: + +```powershell +Get-NetTCPConnection -LocalPort 7860,8000 -ErrorAction SilentlyContinue +``` + +### The page opens but API requests fail + +Open , or the corresponding custom backend +port. If the health request fails, inspect the backend error in the launcher +terminal. In manual mode, confirm that the frontend's `API_BASE_URL` matches the +backend port. + +### Chat reports a provider, model, or authentication error + +Return to **Settings → Models** and verify all three items: provider credential, +model entry, and selected default model. If environment changes appear to be +ignored, an existing `settings.json.llm` is taking precedence by design. + +### The browser did not open + +Open the printed frontend URL manually. Browser launch failure does not stop +the services; `--no-browser` disables the attempt intentionally. + +### Windows output is garbled + +Stop the launcher and use `webui\scripts\start-webui.ps1` from PowerShell. The +plain `ms-agent ui` command still works, but it cannot retroactively change the +encoding of a parent console that was opened with a legacy code page. + +### `--production` exits immediately + +This is expected. The current one-command mode deliberately runs the React +Router development server and FastAPI for a local source checkout. Production +SSR deployment, static packaging, wheels, and container images are outside this +launcher. diff --git a/webui/README_ZH.md b/webui/README_ZH.md new file mode 100644 index 000000000..723eb378a --- /dev/null +++ b/webui/README_ZH.md @@ -0,0 +1,375 @@ +# MS-Agent WebUI + +[English](./README.md) + +这个目录包含 MS-Agent 的源码工作区 WebUI: + +- `frontend/`:React Router 8 SSR、React 19、Vite 和 Ant Design。 +- `backend/`:FastAPI、MS-Agent SDK 适配层以及 SSE 对话流。 + +当前支持的启动方式面向本地开发工作区。一条 `ms-agent ui` 命令会统一管理 +两个子服务: + +```text +http://127.0.0.1:7860 React Router 开发服务器 + /api/* ────────> http://127.0.0.1:8000 上的 FastAPI +``` + +这不是生产部署方案,也不是独立 wheel 安装方式。命令需要在包含本 +`webui/` 目录的 MS-Agent 源码 checkout 中运行,前端由开发服务器提供。 + + +### 相比旧版 WebUI 的能力变化 + +当前界面替换了此前的 Vite/MUI 版本,定位是通用 Agent 工作区,**不再提供**旧版 +那个专门的 **Deep Research** 视图(`deep_research_worker` / `DeepResearchView` +这套链路)。Agentic Insight v2 请改用 CLI 运行,见 +[`projects/deep_research/v2`](../projects/deep_research/v2/README.md)。 + +### 必须遵守的运行约束 + +- 对话运行时、事件缓冲、turn lock、权限 Future 全部在进程内存里,后端**必须保持 + 单 worker**。加 uvicorn worker 会静默破坏停止/中断、重新接管和授权弹窗。 +- 对话走 SSE,整个栈里没有任何 WebSocket。 +- `--host` 接受任意网卡,而这套栈**没有认证**。绑到非回环地址时,能访问端口的人 + 就能使用这个 Agent——包括它的 shell 工具。 + +## 前置环境 + +| 工具 | 版本要求 | 用途 | +| --- | --- | --- | +| Python | 3.12 或更新版本 | WebUI 后端;`uv` 会创建独立环境 | +| [uv](https://docs.astral.sh/uv/) | 较新版本 | 同步 `webui/backend/.venv` | +| [Node.js](https://nodejs.org/) | **22.22.0 或更新版本** | React Router 8 的硬性要求 | +| [pnpm](https://pnpm.io/installation) | **10.x** | 同步前端依赖;项目固定为 10.17.1 | + +无需预先激活 Python 3.12;uv 会选择兼容解释器,并可在缺失时自动下载。 + +启动前可以检查: + +```bash +python --version +uv --version +node --version +pnpm --version +``` + +如果环境中提供 Corepack,可以这样启用项目固定的 pnpm 版本: + +```bash +corepack enable +corepack prepare pnpm@10.17.1 --activate +``` + +### 不依赖 Corepack 安装工具 + +Node.js 25+ 不再内置 Corepack,而且在 conda 环境里「装了」不等于「会被解析到」—— +PATH 可能仍然命中一个更旧的全局副本。版本检查失败时,启动器会打印它实际解析到的 +可执行文件路径;把两个工具装进**当前激活环境**: + +```bash +pip install uv # uv 装进本环境的 bin/ +npm install --global --prefix "$CONDA_PREFIX" pnpm@10.17.1 +hash -r # 刷新缓存后验证: +command -v uv pnpm # 都应位于 $CONDA_PREFIX/bin +``` + +Node.js < 25 上,`corepack enable && corepack prepare pnpm@10.17.1 --activate` +仍是 pnpm 的可选替代方案。 + +## 快速开始 + +在 MS-Agent 仓库根目录执行: + +```bash +pip install -e . +ms-agent ui +``` + +Windows 也可以使用: + +```powershell +py -m pip install -e . +.\webui\scripts\start-webui.ps1 +``` + +首次启动时,命令会自动完成相当于下面两步的依赖同步: + +```bash +cd webui/backend && uv sync --locked --no-dev --inexact +cd webui/frontend && pnpm install --frozen-lockfile +``` + +后续启动仍会依据 lockfile 快速检查两个环境。同步过程不会安装全局 Python +或 Node 包。两个服务就绪后,浏览器会自动打开 +。 + +在启动终端中按 `Ctrl+C` 会同时停止两个服务。 + +## 配置模型 + +打开 WebUI 不要求预先设置环境变量。真实对话最简单的配置方式是在页面中完成: + +1. 启动 `ms-agent ui`。 +2. 打开“设置 → 模型设置”。 +3. 选择内置供应商,或者添加兼容的自定义供应商。 +4. 根据需要配置 API Key 和 Base URL。 +5. 为该供应商添加模型。 +6. 选择默认供应商和默认模型。 + +除非显式设置 `MS_AGENT_HOME`,这些设置会和常规 MS-Agent CLI/TUI 共享 +`~/.ms_agent`。通过页面保存的供应商凭据会以明文写入该目录下的 +`settings.json`,请勿提交或对外分享该文件。 + +## 配置文件与环境变量 + +后端会按照从通用到具体的顺序读取: + +```text +<仓库根目录>/.env +<仓库根目录>/webui/.env +<仓库根目录>/webui/backend/.env +``` + +最终优先级为: + +```text +真实进程环境 / 启动器注入 + > webui/backend/.env + > webui/.env + > 仓库根目录 .env +``` + +dotenv 文件不会覆盖真实进程环境变量。所有已加载变量也可以供 MCP 配置中的 +`${NAME}` 占位符在运行时解析。上述 `.env` 文件均已被 Git 忽略。 + +需要脚本化或高级配置时,可以复制模板: + +```bash +cp webui/backend/.env.example webui/backend/.env +``` + +PowerShell 对应命令: + +```powershell +Copy-Item .\webui\backend\.env.example .\webui\backend\.env +``` + +### 首次模型初始化变量 + +下面这些变量是浏览器配置的可选替代方案: + +| 变量 | 含义 | +| --- | --- | +| `MS_AGENT_LLM_MODEL` | 要初始化的模型 ID。**不设置它,初始化根本不会执行**,其余三个变量也随之失效。 | +| `MS_AGENT_LLM_PROVIDER` | 初始化使用的 MS-Agent 供应商 ID(默认 `openai`)。必须真的提供上面那个模型:`qwen*` 属于 DashScope/ModelScope,不属于 OpenAI。 | +| `OPENAI_API_KEY` | 凭据,**仅当**供应商是 `openai` 时生效。其他供应商各自读取自己的变量(`DASHSCOPE_API_KEY`、`DEEPSEEK_API_KEY` 等)。 | +| `OPENAI_BASE_URL` | Base URL,同样只在 `openai` 时生效。 | + +初始化只会补充尚不存在的 `llm` 配置。如果 `~/.ms_agent/settings.json` +(或 `MS_AGENT_HOME` 指向的目录)已经包含 `llm`,修改这些环境变量**不会** +覆盖原配置。后续请在“设置 → 模型设置”中修改。 + +### 可选运行变量 + +| 变量 | 含义 | +| --- | --- | +| `MS_AGENT_HOME` | 覆盖 SDK 数据目录;默认是 `~/.ms_agent` | +| `EXA_API_KEY` | 使用 Exa 网页搜索时的可选凭据 | +| 任意 `${NAME}` 对应变量 | 在 MCP 配置中运行时展开 | + +### 由启动器管理的变量 + +正常使用 `ms-agent ui` 时不需要手工设置: + +| 变量 | 管理方式 | +| --- | --- | +| `HOST`、`PORT` | FastAPI 内部地址,由启动参数决定 | +| `API_BASE_URL` | 启动器注入 React Router 进程 | +| `CORS_ORIGINS` | 通常只在手工分别启动服务时需要关注 | + +## 命令参数 + +| 参数 | 默认值 | 说明 | +| --- | --- | --- | +| `--host HOST` | `127.0.0.1` | 前端监听地址 | +| `--port PORT` | `7860` | 前端端口和浏览器访问端口 | +| `--backend-port PORT` | `8000` | 内部 FastAPI 端口 | +| `--reload` | 关闭 | Python 后端源码变化时自动重载;前端始终启用 HMR | +| `--skip-install` | 关闭 | 跳过两项依赖同步;任一项目本地环境缺失时会报错。使用它时只需要 PATH 上有 Node.js——`uv` 与 `pnpm` 完全不会被解析 | +| `--no-browser` | 关闭 | 不自动打开浏览器 | +| `--production` | 不支持 | 保留参数,使用时会明确报错并退出 | + +示例: + +```bash +# 修改前后端端口 +ms-agent ui --port 8080 --backend-port 8001 + +# 后端源码变化时自动重载 +ms-agent ui --reload + +# 不自动打开浏览器 +ms-agent ui --no-browser + +# 明确允许局域网访问前端 +ms-agent ui --host 0.0.0.0 +``` + +后端仍然只监听 `127.0.0.1`,浏览器 API 请求通过前端代理。对外暴露开发 +服务器并不等同于生产部署,也不会自动增加身份验证或生产级安全能力。 + +## 手工分别启动两个服务 + +需要独立调试前后端时,可以使用两个终端。正常使用不需要执行这些步骤。 + +### 1. 后端 + +模型凭据来自 `webui/backend/.env`(参考 `.env.example`),手工启动前先复制一份。 + +```bash +cd webui/backend +uv sync --locked +uv run --frozen dev +``` + +后端监听 ,健康检查地址是 +。后端依赖以 editable 方式指向当前所在的 +MS-Agent checkout,因此框架源码修改可以直接生效。 + +### 2. 前端 + +在另一个终端执行: + +```bash +cd webui/frontend +pnpm install --frozen-lockfile +pnpm dev +``` + +打开 。Vite 开发服务器会把 `/api/*` 代理到 +`http://127.0.0.1:8000`,SSR 路由 loader 默认也使用这个地址。如果手工修改 +后端端口,需要在启动前端进程前相应设置 `API_BASE_URL`。 + +## 测试 + +后端测试位于 `webui/backend/tests`,在后端自己的环境里运行。注意启动器同步该环境时 +**不含** dev 组,测试前先补装一次: + +```bash +cd webui/backend +uv sync --locked # 含 dev 组(pytest) +./.venv/bin/python -m pytest +``` + +启动器/契约测试放在仓库的 tests 里,任何装有 SDK 的 Python 都能跑: + +```bash +python -m pytest tests/cli/test_ui.py tests/ui +``` + +前端暂无自动化测试;`pnpm typecheck` 是门禁,UI 回归靠真实 Chrome 走查。 + +仓库 CI(`pytest tests`)**不包含** `webui/backend/tests`——它的依赖(FastAPI 等) +没有装在那里。改动后端或它消费的 SDK 面时,请按上面的方式在本地运行。 + +## Windows + +推荐使用 PowerShell。在仓库根目录执行随项目提供的 UTF-8 启动脚本: + +```powershell +.\webui\scripts\start-webui.ps1 +``` + +所有启动参数都会原样转发: + +```powershell +.\webui\scripts\start-webui.ps1 --reload --no-browser +``` + +这个脚本会把当前控制台切换为 UTF-8,并设置 `PYTHONUTF8` 和 +`PYTHONIOENCODING`,保留了此前 Windows 用户反馈控制台乱码后加入的专项修复。 + +如果本机 PowerShell 执行策略阻止脚本,只为当前进程临时放开: + +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\webui\scripts\start-webui.ps1 +``` + +这不会修改机器级或用户级策略。启动器使用 Windows 进程组;按 `Ctrl+C` 时会 +关闭后代 Python/Node 进程。内置终端使用 Windows 原生命令处理器,不依赖额外 +的 POSIX `sh`。支持包含空格和非 ASCII 字符的仓库路径;为了获得更可靠的文件 +监听效果,建议把仓库放在本地文件系统中。 + +Windows 环境检查: + +```powershell +Get-Command ms-agent, uv, node, pnpm +node --version +pnpm --version +``` + +## 常见问题 + +### 找不到必需命令 + +安装缺少的工具,重新打开终端以刷新 `PATH`,然后执行上面的版本检查。启动器 +会在安装依赖前拒绝低于 22.22.0 的 Node,以及不属于 10.x 系列的 pnpm。 + +### 依赖同步失败 + +首次启动需要下载 Python 和 Node 依赖,可能需要一些时间。检查软件源与网络, +然后重新执行 `ms-agent ui`。如需单独观察失败步骤,可运行“手工分别启动”一节 +中的两条同步命令。只有在 `webui/backend/.venv` 与 +`webui/frontend/node_modules` 都已存在时才适合使用 `--skip-install`。 + +### 端口已被占用 + +同时指定新的前后端端口: + +启动器会在同步任何依赖**之前**检查两个端口,所以失败很快且会点名端口。另外有两条 +强制规则: + +- `--port` 与 `--backend-port` 不能相同; +- 前端使用 `--strictPort`,端口被占用是硬失败,不会自动顺延到下一个端口。 + +显式指定两个端口: + +```bash +ms-agent ui --port 8080 --backend-port 8001 +``` + +Windows 可以这样检查默认端口: + +```powershell +Get-NetTCPConnection -LocalPort 7860,8000 -ErrorAction SilentlyContinue +``` + +### 页面能打开,但 API 请求失败 + +访问 ,或对应的自定义后端端口。如果健康 +检查失败,请查看启动终端中的后端错误。手工启动时还要确认前端 +`API_BASE_URL` 与后端端口一致。 + +### 对话提示供应商、模型或认证错误 + +回到“设置 → 模型设置”,同时检查供应商凭据、模型条目和已选择的默认模型。 +如果修改环境变量后没有变化,通常是已有 `settings.json.llm` 按设计保持了更高 +的配置真值,应改用页面设置。 + +### 浏览器没有自动打开 + +手工打开启动器打印的前端地址即可。浏览器打开失败不会停止服务; +`--no-browser` 会主动禁用这一步。 + +### Windows 控制台乱码 + +停止启动器,改用 PowerShell 中的 `webui\scripts\start-webui.ps1`。直接执行 +`ms-agent ui` 仍然可用,但它无法反向修改已经用传统代码页启动的父控制台编码。 + +### `--production` 立即退出 + +这是预期行为。当前一命令模式刻意面向本地源码 checkout,运行 React Router +开发服务器与 FastAPI。生产 SSR 部署、静态打包、wheel 和镜像不属于这个启动器 +的职责范围。 diff --git a/webui/backend/.env.example b/webui/backend/.env.example new file mode 100644 index 000000000..0882ece40 --- /dev/null +++ b/webui/backend/.env.example @@ -0,0 +1,36 @@ +# Server +HOST=127.0.0.1 +PORT=8000 + +# CORS — comma-separated origins allowed in dev. Only relevant when you start +# the two services by hand and the browser talks to :8000 cross-origin; +# `ms-agent ui` serves the frontend on :7860 and proxies /api, so it never +# triggers CORS. 5173 is the Vite default for a manual `pnpm dev`. +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:7860,http://127.0.0.1:7860 + +# Credentials for the `anthropic` provider (also used by any provider whose +# `protocol` is set to "anthropic", e.g. a DeepSeek /anthropic gateway). +ANTHROPIC_API_KEY= + +# --- ms_agent backend LLM credentials (also used to bootstrap settings.json) --- +# Point at any OpenAI-compatible gateway (e.g. DashScope compatible-mode). +OPENAI_API_KEY= +OPENAI_BASE_URL= + +# Which provider/model the chat runtime uses when settings.json has no llm block. +# provider must be a known registry id: openai | modelscope | dashscope | zhipu | +# kimi | deepseek | minimax | openrouter | anthropic | google +# Keep the pair coherent — the model must exist AT that provider. A mismatch is +# not cosmetic: the model is registered under the provider and can shadow the +# correct one in the picker. qwen* models are DashScope/ModelScope, not OpenAI. +# Bootstrap only runs when MS_AGENT_LLM_MODEL is set; the other vars are +# ignored without it. +MS_AGENT_LLM_PROVIDER=dashscope +MS_AGENT_LLM_MODEL=qwen3.7-plus + +# SDK home. Leave empty to share the SDK default (~/.ms_agent) with CLI/TUI, +# or set an isolated path such as ~/.ms_agent_webui. +MS_AGENT_HOME= + +# Optional: third-party keys passed through to the SDK env (e.g. web-search MCP) +EXA_API_KEY= diff --git a/webui/backend/.python-version b/webui/backend/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/webui/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/webui/backend/agent_runner.py b/webui/backend/agent_runner.py deleted file mode 100644 index 3f3879ac1..000000000 --- a/webui/backend/agent_runner.py +++ /dev/null @@ -1,1689 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Agent runner for MS-Agent Web UI -Manages the execution of ms-agent through subprocess with log streaming. -""" -import asyncio -import os -import re -import signal -import subprocess -import sys -import yaml -from datetime import datetime -from typing import Any, Callable, Dict, Optional - - -class AgentRunner: - """Runs ms-agent as a subprocess with output streaming""" - - def __init__(self, - session_id: str, - project: Dict[str, Any], - config_manager, - on_output: Callable[[Dict[str, Any]], None] = None, - on_log: Callable[[Dict[str, Any]], None] = None, - on_progress: Callable[[Dict[str, Any]], None] = None, - on_complete: Callable[[Dict[str, Any]], None] = None, - on_error: Callable[[Dict[str, Any]], None] = None, - workflow_type: str = 'standard'): - self.session_id = session_id - self.project = project - self.config_manager = config_manager - self.on_output = on_output - self.on_log = on_log - self.on_progress = on_progress - self.on_complete = on_complete - self.on_error = on_error - self._workflow_type = workflow_type - - self.process: Optional[asyncio.subprocess.Process] = None - self.is_running = False - self._accumulated_output = '' - self._current_step = None - self._workflow_steps = [] - self._stop_requested = False - self._waiting_for_input = False # Track if agent is waiting for user input - self._waiting_input_sent = False # Track if waiting_input message was already sent - self._collecting_assistant_output = False # Track if we're collecting assistant output - self._collecting_tool_call = False # Track if we're collecting tool call info - self._collecting_tool_result = False # Track if we're collecting tool result - self._current_tool_name = None # Current tool being called - self._current_tool_args = None # Current tool arguments - self._current_tool_result = None # Current tool result - self._tool_call_json_buffer = '' # Buffer for collecting multi-line JSON tool call info - self._is_chat_mode = project.get( - 'id') == '__chat__' # Simple chat mode flag - self._chat_response_buffer = '' # Buffer for chat mode responses - - async def start(self, query: str): - """Start the agent""" - try: - self._stop_requested = False - self.is_running = True - - # Build command based on project type - cmd = self._build_command(query) - env = self._build_env() - - print('[Runner] Starting agent with command:') - print(f"[Runner] {' '.join(cmd)}") - print(f"[Runner] Working directory: {self.project['path']}") - - # Log the command - if self.on_log: - self.on_log({ - 'level': 'info', - 'message': f'Starting agent: {" ".join(cmd[:5])}...', - 'timestamp': datetime.now().isoformat() - }) - - # Start subprocess - self.process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE, - env=env, - cwd=self.project['path'], - start_new_session=True) - - print(f'[Runner] Process started with PID: {self.process.pid}') - - # Start output reader - await self._read_output() - - except Exception as e: - print(f'[Runner] ERROR: {e}') - import traceback - traceback.print_exc() - if self.on_error: - self.on_error({'message': str(e), 'type': 'startup_error'}) - - async def stop(self): - """Stop the agent""" - self._stop_requested = True - self.is_running = False - if not self.process: - return - - try: - # If already exited, nothing to do - if self.process.returncode is not None: - return - - # Prefer terminating the whole process group to stop child processes too - try: - os.killpg(self.process.pid, signal.SIGTERM) - except Exception: - # Fallback to terminating only the parent - try: - self.process.terminate() - except Exception: - pass - - try: - await asyncio.wait_for(self.process.wait(), timeout=5.0) - except asyncio.TimeoutError: - try: - os.killpg(self.process.pid, signal.SIGKILL) - except Exception: - try: - self.process.kill() - except Exception: - pass - except Exception: - pass - - async def send_input(self, text: str): - """Send input to the agent""" - # Check if process is still alive and stdin is available - if not self.process: - print('[Runner] ERROR: Process is None, cannot send input') - if self.on_error: - self.on_error({ - 'message': - 'Agent process is not running. Please start a new conversation.', - 'type': 'input_error' - }) - return - - # Check if process has exited - if self.process.returncode is not None: - print( - f'[Runner] ERROR: Process has exited with code {self.process.returncode}, cannot send input' - ) - if self.on_error: - self.on_error({ - 'message': - 'Agent process has terminated. Please start a new conversation.', - 'type': 'input_error' - }) - return - - # Check if stdin is available - if not self.process.stdin: - print('[Runner] ERROR: Process stdin is None, cannot send input') - if self.on_error: - self.on_error({ - 'message': - 'Cannot send input: process stdin is not available.', - 'type': 'input_error' - }) - return - - print(f'[Runner] Sending input to agent: {text[:100]}...') - self._waiting_for_input = False # Reset waiting flag when sending input - self._waiting_input_sent = False # Reset so it can be sent again after next completion - self.is_running = True # Ensure process is marked as running - # Reset chat mode collection state for next response - self._collecting_assistant_output = False - self._chat_response_buffer = '' - - try: - self.process.stdin.write((text + '\n').encode()) - await self.process.stdin.drain() - print('[Runner] Input sent successfully') - except (BrokenPipeError, RuntimeError, OSError) as e: - print(f'[Runner] ERROR: Failed to send input: {e}') - if self.on_error: - self.on_error({ - 'message': - f'Failed to send input: Process may have terminated. Error: {str(e)}', - 'type': 'input_error' - }) - # Mark process as not running - self.is_running = False - self._waiting_for_input = False - - def _build_command(self, query: str) -> list: - """Build the command to run the agent""" - project_type = self.project.get('type') - project_path = self.project['path'] - config_file = self.project.get('config_file', '') - - # Get workflow_type from session if available - # This allows switching between standard and simple workflow for code_genesis - workflow_type = getattr(self, '_workflow_type', 'standard') - if workflow_type == 'simple' and project_type == 'workflow': - # For code_genesis with simple workflow, use simple_workflow.yaml - simple_config_file = os.path.join(project_path, - 'simple_workflow.yaml') - if os.path.exists(simple_config_file): - config_file = simple_config_file - - # Get python executable - python = sys.executable - - # Get MCP config file path - mcp_file = self.config_manager.get_mcp_file_path() - - if project_type == 'workflow' or project_type == 'agent': - # Use ms-agent CLI command (installed via entry point) - cmd = [ - 'ms-agent', 'run', '--config', config_file, - '--trust_remote_code', 'true' - ] - - if query: - cmd.extend(['--query', query]) - - if os.path.exists(mcp_file): - cmd.extend(['--mcp_server_file', mcp_file]) - - # Add LLM config from user settings - llm_config = self.config_manager.get_llm_config() - temperature_enabled = bool( - llm_config.get('temperature_enabled', False)) - if llm_config.get('api_key'): - provider = llm_config.get('provider', 'modelscope') - if provider == 'modelscope': - cmd.extend( - ['--llm.modelscope_api_key', llm_config['api_key']]) - # Set llm.service to modelscope to ensure the correct service is used - cmd.extend(['--llm.service', 'modelscope']) - # Pass base_url if set by user - if llm_config.get('base_url'): - cmd.extend([ - '--llm.modelscope_base_url', llm_config['base_url'] - ]) - # Pass model if set by user - if llm_config.get('model'): - cmd.extend(['--llm.model', llm_config['model']]) - # Pass temperature if set by user (in generation_config) - if temperature_enabled and llm_config.get( - 'temperature') is not None: - cmd.extend([ - '--generation_config.temperature', - str(llm_config['temperature']) - ]) - # Pass max_tokens if set by user (in generation_config) - if llm_config.get('max_tokens'): - cmd.extend([ - '--generation_config.max_tokens', - str(llm_config['max_tokens']) - ]) - elif provider == 'openai': - cmd.extend(['--llm.openai_api_key', llm_config['api_key']]) - # Set llm.service to openai to ensure the correct service is used - cmd.extend(['--llm.service', 'openai']) - # Pass base_url if set by user - if llm_config.get('base_url'): - cmd.extend( - ['--llm.openai_base_url', llm_config['base_url']]) - # Pass model if set by user - if llm_config.get('model'): - cmd.extend(['--llm.model', llm_config['model']]) - # Pass temperature if set by user (in generation_config) - if temperature_enabled and llm_config.get( - 'temperature') is not None: - cmd.extend([ - '--generation_config.temperature', - str(llm_config['temperature']) - ]) - # Pass max_tokens if set by user (in generation_config) - if llm_config.get('max_tokens'): - cmd.extend([ - '--generation_config.max_tokens', - str(llm_config['max_tokens']) - ]) - - # Add edit_file_config from user settings (skip for chat mode) - if self.project.get('id') != '__chat__': - edit_file_config = self.config_manager.get_edit_file_config() - if edit_file_config.get('api_key'): - # If API key is provided, pass edit_file_config - cmd.extend([ - '--tools.file_system.edit_file_config.api_key', - edit_file_config['api_key'] - ]) - if edit_file_config.get('base_url'): - cmd.extend([ - '--tools.file_system.edit_file_config.base_url', - edit_file_config['base_url'] - ]) - if edit_file_config.get('diff_model'): - cmd.extend([ - '--tools.file_system.edit_file_config.diff_model', - edit_file_config['diff_model'] - ]) - else: - # If no API key, exclude edit_file from tools - # Read the current include list from config file and remove edit_file - try: - with open(config_file, 'r', encoding='utf-8') as f: - config_data = yaml.safe_load(f) - if config_data and 'tools' in config_data and 'file_system' in config_data[ - 'tools']: - include_list = config_data['tools'][ - 'file_system'].get('include', []) - if isinstance( - include_list, - list) and 'edit_file' in include_list: - # Remove edit_file from the list - filtered_include = [ - tool for tool in include_list - if tool != 'edit_file' - ] - # Pass the filtered list as comma-separated string - cmd.extend([ - '--tools.file_system.include', - ','.join(filtered_include) - ]) - except Exception as e: - print( - f'[Runner] Warning: Could not read config file to exclude edit_file: {e}' - ) - # Fallback: explicitly exclude edit_file - cmd.extend( - ['--tools.file_system.exclude', 'edit_file']) - - # Add EdgeOne Pages API token and project name from user settings - edgeone_pages_config = self.config_manager.get_edgeone_pages_config( - ) - if edgeone_pages_config.get('api_token'): - # If API token is provided, pass it to the MCP server config - cmd.extend([ - '--tools.edgeone-pages-mcp.env.EDGEONE_PAGES_API_TOKEN', - edgeone_pages_config['api_token'] - ]) - if edgeone_pages_config.get('project_name'): - # If project name is provided, pass it to the MCP server config - cmd.extend([ - '--tools.edgeone-pages-mcp.env.EDGEONE_PAGES_PROJECT_NAME', - edgeone_pages_config['project_name'] - ]) - - elif project_type == 'script': - # Run the script directly - cmd = [python, self.project['config_file']] - else: - cmd = [python, '-m', 'ms_agent', 'run', '--config', project_path] - - return cmd - - def _build_env(self) -> Dict[str, str]: - """Build environment variables""" - env = os.environ.copy() - - # Add config env vars - env.update(self.config_manager.get_env_vars()) - - # Set PYTHONUNBUFFERED for real-time output - env['PYTHONUNBUFFERED'] = '1' - - return env - - async def _read_output(self): - """Read and process output from the subprocess""" - print('[Runner] Starting to read output...') - process_exited = False - empty_line_count = 0 # Track consecutive empty lines after process exit - try: - # Continue reading even after process exits to catch all remaining output - while (self.is_running or process_exited) and self.process: - # Check if process has exited - if self.process.returncode is not None and not process_exited: - process_exited = True - print( - f'[Runner] Process exited with code: {self.process.returncode}' - ) - # Continue reading remaining output even after process exits - # This ensures we don't miss any URLs or important messages - if not self.process.stdout: - # If stdout is closed, we can't read more - if self._waiting_for_input: - self._waiting_for_input = False - break - - # Check if stdout is still available - if not self.process.stdout: - print('[Runner] Process stdout is closed') - break - - try: - # Use shorter timeout after process exits to read remaining data faster - timeout = 0.1 if process_exited else 1.0 - line = await asyncio.wait_for( - self.process.stdout.readline(), timeout=timeout) - except asyncio.TimeoutError: - # Timeout - check if we're waiting for input - if self._waiting_for_input: - # Check if process is still alive - if self.process.returncode is None: - # Flush any pending chat response before waiting - if self._is_chat_mode: - self._flush_chat_response() - # Send waiting_input message to enable frontend input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': '', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - # Process is still alive, continue waiting - continue - else: - # Process exited, but continue reading remaining output - # Try a few more times before giving up - if empty_line_count < 3: - continue - break - # Not waiting for input, check if process is still alive - if self.process.returncode is not None: - # Process exited, try a few more times before giving up - if empty_line_count < 3: - continue - break - continue - - if not line: - # Empty line - check context - if process_exited: - # After process exit, count consecutive empty lines - empty_line_count += 1 - # If we get 3 consecutive empty lines/timeouts, assume no more data - if empty_line_count >= 3: - print('[Runner] No more output after process exit') - break - # Continue trying to read more - continue - - # Check if agent is waiting for input before breaking - if self._waiting_for_input: - # Check if process is still alive - if self.process.returncode is None: - # Flush any pending chat response before waiting - if self._is_chat_mode: - self._flush_chat_response() - # Send waiting_input message to enable frontend input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': '', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - print( - '[Runner] Agent is waiting for user input, keeping process alive...' - ) - # Keep process alive and wait for input - await asyncio.sleep( - 0.5) # Small delay to avoid busy waiting - continue - else: - print( - '[Runner] Process exited while waiting for input' - ) - # Process exited, but continue reading any remaining output - # Don't break yet - there might be more data in stdout buffer - process_exited = True - continue - print('[Runner] No more output, breaking...') - break - - # Reset empty line count when we get actual data - empty_line_count = 0 - text = line.decode('utf-8', errors='replace').rstrip() - print(f'[Runner] Output: {text[:200]}' - if len(text) > 200 else f'[Runner] Output: {text}') - try: - await self._process_line(text) - except Exception as e: - print(f'[Runner] ERROR processing line: {e}') - import traceback - traceback.print_exc() - - # Wait for process to complete and handle completion - if self.process: - # Get return code if not already available - if self.process.returncode is None: - return_code = await self.process.wait() - else: - return_code = self.process.returncode - - print(f'[Runner] Process exited with code: {return_code}') - - # Flush chat response for chat mode - self._flush_chat_response() - - # Flush any accumulated assistant output before handling completion - if self._collecting_assistant_output and self._accumulated_output.strip( - ): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - print( - f'[Runner] Flushing accumulated output on process exit: {cleaned[:200]}...' - ) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step or 'agent' - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - # If stop was requested, do not report as completion/error - if self._stop_requested: - if self.on_log: - self.on_log({ - 'level': 'info', - 'message': 'Agent stopped by user', - 'timestamp': datetime.now().isoformat() - }) - return - - # Complete current step if any before handling exit - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - # If Refine step completes successfully, it should be waiting for input - if return_code == 0 and self._current_step.lower( - ) == 'refine': - self._waiting_for_input = True - self._current_step = None - - # If was waiting for input but process exited, clear waiting state - if self._waiting_for_input: - self._waiting_for_input = False - # If process completed successfully, send completion message - if return_code == 0: - # Send waiting_input message if not already sent - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': - 'waiting_input', - 'content': - ('✅ Initial refinement completed. ' - 'You can now provide additional feedback or modifications.' - ), - 'role': - 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - if self.on_complete: - self.on_complete({ - 'status': - 'success', - 'message': - 'Agent completed successfully' - }) - else: - if self.on_error: - self.on_error({ - 'message': - ('Agent process terminated while waiting for input. ' - f'Exit code: {return_code}'), - 'type': - 'process_exit_error', - 'code': - return_code - }) - elif return_code == 0: - if self.on_complete: - self.on_complete({ - 'status': - 'success', - 'message': - 'Agent completed successfully' - }) - else: - if self.on_error: - self.on_error({ - 'message': f'Agent exited with code {return_code}', - 'type': 'exit_error', - 'code': return_code - }) - - except Exception as e: - print(f'[Runner] Read error: {e}') - import traceback - traceback.print_exc() - if not self._stop_requested and self.on_error: - self.on_error({'message': str(e), 'type': 'read_error'}) - finally: - if not self._waiting_for_input: - self.is_running = False - print('[Runner] Finished reading output') - else: - print('[Runner] Process waiting for input, keeping alive...') - - @staticmethod - def _clean_log_prefix(text: str) -> str: - """Remove log prefixes like [INFO:ms_agent] [agent_name]""" - # Remove [INFO:ms_agent] prefix - text = re.sub(r'\[INFO:ms_agent\]\s*', '', text) - # Remove [agent_name] prefix (e.g., [orchestrator]) - text = re.sub(r'^\[([^\]]+)\]\s*', '', text) - return text.strip() - - async def _process_chat_line(self, line: str): - """Simple chat mode - handle assistant output, tool calls, and tool results""" - cleaned = self._clean_log_prefix(line) - - # Detect [tool_calling]: marker - flush assistant output and start collecting tool call - if '[tool_calling]:' in line: - self._flush_chat_response() - self._collecting_tool_call = True - self._tool_call_json_buffer = '' - return - - # Collect tool call JSON - if self._collecting_tool_call: - if cleaned: - if self._tool_call_json_buffer: - self._tool_call_json_buffer += '\n' + cleaned - else: - self._tool_call_json_buffer = cleaned - # Check if we have a complete JSON object - if cleaned == '}' and self._tool_call_json_buffer.strip( - ).startswith('{'): - self._flush_tool_call() - return - - # Detect tool execution result (success or error) - if 'execute tool call' in line: - if self.on_output: - is_error = 'error' in line.lower() - self.on_output({ - 'type': 'tool_result', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'is_error': is_error - } - }) - return - - # Detect [assistant]: marker - start collecting - if '[assistant]:' in line: - self._flush_chat_response() - self._collecting_assistant_output = True - self._chat_response_buffer = '' - return - - # Detect end markers - flush assistant output - end_markers = ['[user]:'] - for marker in end_markers: - if marker in line: - self._flush_chat_response() - return - - # If collecting assistant output, accumulate the content - if self._collecting_assistant_output: - if cleaned: - if self._chat_response_buffer: - self._chat_response_buffer += '\n' + cleaned - else: - self._chat_response_buffer = cleaned - # Mark as waiting for input - process is still running - self._waiting_for_input = True - - def _flush_tool_call(self): - """Send tool call information to frontend""" - if self._is_chat_mode and self._tool_call_json_buffer.strip( - ) and self.on_output: - try: - import json - tool_data = json.loads(self._tool_call_json_buffer) - tool_name = tool_data.get('tool_name', 'unknown') - print(f'[Runner] Tool call: {tool_name}') - self.on_output({ - 'type': 'tool_call', - 'content': '', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'arguments': tool_data.get('arguments', {}), - 'id': tool_data.get('id', '') - } - }) - except json.JSONDecodeError: - print('[Runner] Failed to parse tool call JSON') - self._tool_call_json_buffer = '' - self._collecting_tool_call = False - - def _flush_chat_response(self): - """Send final chat response with done=True""" - if self._is_chat_mode and self._chat_response_buffer.strip( - ) and self.on_output: - print( - f'[Runner] Chat complete: {len(self._chat_response_buffer)} chars' - ) - self.on_output({ - 'type': 'stream', - 'content': self._chat_response_buffer.strip(), - 'role': 'assistant', - 'done': True - }) - self._chat_response_buffer = '' - # Don't reset _collecting_assistant_output here - more content may come - # It will be reset when we see [tool_calling]: or [user]: or process exits - - async def _process_line(self, line: str): - """Process a line of output""" - # Skip usage statistics lines - if '[usage]' in line or '[usage_total]' in line: - return - - # Simple chat mode: just capture assistant output - if self._is_chat_mode: - await self._process_chat_line(line) - return - - # Skip lines without agent name (generic system messages) - # Pattern: [INFO:ms_agent] without [agent_name] afterwards - if '[INFO:ms_agent]' in line: - # Check if there's an agent name tag [xxx] after [INFO:ms_agent] - import re - if not re.search(r'\[INFO:ms_agent\]\s*\[([^\]]+)\]', line): - return - - # Log the cleaned line - if self.on_log: - log_level = self._detect_log_level(line) - cleaned_message = self._clean_log_prefix(line) - await self.on_log({ - 'level': - log_level, - 'message': - cleaned_message if cleaned_message else line, - 'timestamp': - datetime.now().isoformat() - }) - - # Parse for special patterns (use original line for pattern matching) - await self._detect_patterns(line) - - def _detect_log_level(self, line: str) -> str: - """Detect log level from line""" - line_lower = line.lower() - if '[error' in line_lower or 'error:' in line_lower: - return 'error' - elif '[warn' in line_lower or 'warning:' in line_lower: - return 'warning' - elif '[debug' in line_lower: - return 'debug' - return 'info' - - def _scan_and_send_output_files(self, programmer_step=None): - """Read tasks.txt to get all generated files with their completion status""" - try: - project_path = self.project.get('path') - if not project_path: - return - - # tasks.txt path: projects/code_genesis/output/tasks.txt - tasks_file = os.path.join(project_path, 'output', 'tasks.txt') - - if not os.path.exists(tasks_file): - print(f'[Runner] tasks.txt not found: {tasks_file}') - return - - print(f'[Runner] Reading tasks.txt: {tasks_file}') - - # Read and parse tasks.txt - with open(tasks_file, 'r', encoding='utf-8') as f: - lines = f.readlines() - - generated_files = [] - - for line in lines: - line = line.strip() - # Skip header line and empty lines - if not line or line.startswith('Files in'): - continue - - # Parse format: "css/styles.css: ✅Built" - if ':' in line and '✅' in line: - file_path = line.split(':')[0].strip() - generated_files.append(file_path) - - print( - f'[Runner] Found {len(generated_files)} files in tasks.txt: {generated_files}' - ) - - # Send all files in one batch - if generated_files and self.on_output: - self.on_output({ - 'type': 'file_output', - 'content': generated_files, # Send as array - 'role': 'assistant', - 'metadata': { - 'files': generated_files, - 'source': 'tasks.txt' - } - }) - - except Exception as e: - print(f'[Runner] Error reading tasks.txt: {e}') - import traceback - traceback.print_exc() - - async def _detect_patterns(self, line: str): - """Detect special patterns in output""" - # IMPORTANT: Check for deployment URL FIRST, before any other patterns that might return early - # This ensures URLs are always detected even if other patterns match - url_match = None - # Pattern 1: "url": "https://..." - url_match = re.search(r'"url":\s*"(https?://[^"]+)"', line) - # Pattern 2: Direct URL like "https://mcp.edgeone.site/share/..." - if not url_match: - url_match = re.search(r'(https?://mcp\.edgeone\.site/[^\s]+)', - line) - # Pattern 3: EdgeOne Pages URL like "https://...edgeone.cool?..." - # BUT skip if this is a curl command line (testing command, not actual deployment URL) - if not url_match and 'curl -s' not in line and 'curl ' not in line: - url_match = re.search(r'(https?://[^\s]*edgeone\.cool[^\s]*)', - line) - # Pattern 4: Also check for edgeone.site URLs in any format (fallback) - # BUT skip if this is a curl command line - if not url_match and 'curl -s' not in line and 'curl ' not in line: - url_match = re.search(r'(https?://[^\s]*edgeone\.site[^\s]*)', - line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace('\\&', '&') - print( - f'[Runner] Detected deployment URL (early): {deployment_url} from line: {line[:100]}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # Continue processing - don't return yet, other patterns might also match - - # Detect OpenAI API errors and other API errors - # Check for OpenAI error patterns - if 'openai.' in line.lower() and ('error' in line.lower() - or 'Error' in line): - error_message = line.strip() - # Try to extract error details from the line - # Pattern: openai.NotFoundError: Error code: 404 - {'error': {'message': '...', ...}} - json_match = re.search(r'\{.*?\}', error_message, re.DOTALL) - if json_match: - try: - import json - error_data = json.loads(json_match.group(0)) - if 'error' in error_data and 'message' in error_data[ - 'error']: - error_msg = error_data['error']['message'] - error_type = error_data['error'].get( - 'type', 'API Error') - error_message = f'**{error_type}**: {error_msg}' - except Exception: - pass - - print(f'[Runner] Detected API error: {error_message}') - if self.on_error: - self.on_error({'message': error_message, 'type': 'api_error'}) - # Also send as output message so it appears in the conversation - if self.on_output: - self.on_output({ - 'type': 'error', - 'content': error_message, - 'role': 'system', - 'metadata': { - 'error_type': 'api_error' - } - }) - return - - # Detect other error patterns - error_patterns = [ - r'Error code:\s*(\d+)\s*-\s*({.*?})', - ] - - for pattern in error_patterns: - error_match = re.search(pattern, line, re.IGNORECASE | re.DOTALL) - if error_match: - error_message = line.strip() - # Try to extract JSON error details if available - json_match = re.search(r'\{.*?\}', error_message, re.DOTALL) - if json_match: - try: - import json - error_data = json.loads(json_match.group(0)) - if 'error' in error_data and 'message' in error_data[ - 'error']: - error_msg = error_data['error']['message'] - error_type = error_data['error'].get( - 'type', 'API Error') - error_message = f'**{error_type}**: {error_msg}' - except Exception: - pass - - print(f'[Runner] Detected API error: {error_message}') - if self.on_error: - self.on_error({ - 'message': - error_message, - 'type': - 'api_error', - 'code': - error_match.group(1) if error_match.groups() else None - }) - # Also send as output message so it appears in the conversation - if self.on_output: - self.on_output({ - 'type': 'error', - 'content': error_message, - 'role': 'system', - 'metadata': { - 'error_type': 'api_error' - } - }) - return - - # Detect workflow step beginning: "[tag] Agent tag task beginning." - begin_match = re.search( - r'\[([^\]]+)\]\s*Agent\s+\S+\s+task\s+beginning', line) - if begin_match: - step_name = begin_match.group(1) - - # Skip sub-steps and programmer agents (handled separately) - if (('-r' in step_name and '-' in step_name.split('-r')[-1]) - or step_name.startswith('programmer-')): - return - - print(f'[Runner] Step beginning: {step_name}') - - # Flush previous step if exists - if self._current_step and self._accumulated_output.strip(): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - - # Start new step - self._current_step = step_name - if step_name not in self._workflow_steps: - self._workflow_steps.append(step_name) - - step_status = { - s: ('completed' if i < self._workflow_steps.index(step_name) - else 'running' if s == step_name else 'pending') - for i, s in enumerate(self._workflow_steps) - } - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': step_name, - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - if self.on_output: - self.on_output({ - 'type': 'step_start', - 'content': step_name, - 'role': 'assistant', - 'metadata': { - 'step': step_name, - 'status': 'running' - } - }) - - # If Refine step is starting, scan tasks.txt for all generated files - # This ensures files are detected after Coding phase completes - if step_name.lower() == 'refine': - self._scan_and_send_output_files() - - return - - # Detect programmer-xxx pattern (first occurrence signals coding start) - programmer_match = re.search(r'\[programmer-([^\]]+)\]', line) - if programmer_match: - programmer_agent = f'programmer-{programmer_match.group(1)}' - - # If this is FIRST programmer agent, trigger coding step start - if not self._current_step or not self._current_step.startswith( - 'programmer-'): - print( - f'[Runner] First programmer agent detected: {programmer_agent} - starting coding step' - ) - - # Flush previous step's output - if self._current_step and self._accumulated_output.strip(): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - # Mark previous step complete - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - - # Start coding step - self._current_step = programmer_agent - if 'coding' not in self._workflow_steps: - self._workflow_steps.append('coding') - - step_status = { - s: ('completed' if i < self._workflow_steps.index('coding') - else 'running' if s == 'coding' else 'pending') - for i, s in enumerate(self._workflow_steps) - } - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': 'coding', - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - if self.on_output: - self.on_output({ - 'type': 'step_start', - 'content': 'coding', - 'role': 'assistant', - 'metadata': { - 'step': 'coding', - 'status': 'running' - } - }) - - # Update current programmer agent - elif programmer_agent != self._current_step: - self._current_step = programmer_agent - - # Helper to flush accumulated assistant output - def flush_accumulated_output(): - print(f'[Runner] flush_accumulated_output called: ' - f'collecting={self._collecting_assistant_output}, ' - f'buffer_len={len(self._accumulated_output)}') - print( - f'[Runner] Buffer content: {self._accumulated_output[:200]}...' - if len(self._accumulated_output) > 200 else - f'[Runner] Buffer content: {self._accumulated_output}') - if self._collecting_assistant_output and self._accumulated_output.strip( - ): - # Clean log prefixes - cleaned_content = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned_content = re.sub( - r'\[([^\]]+)\]\s*', '', cleaned_content, count=1) - print( - f'[Runner] Flushing assistant output: {cleaned_content[:100]}...' - ) - - # Map agent name for display - agent_name = self._current_step or 'agent' - display_agent = agent_name - if agent_name.startswith('programmer-'): - display_agent = 'coding' - - if cleaned_content and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned_content, - 'role': 'assistant', - 'metadata': { - 'agent': display_agent - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - else: - print(f'[Runner] flush_accumulated_output skipped: ' - f'collecting={self._collecting_assistant_output}, ' - f'has_content={bool(self._accumulated_output.strip())}') - - # Detect workflow step finished: "[tag] Agent tag task finished." - end_match = re.search(r'\[([^\]]+)\]\s*Agent\s+\S+\s+task\s+finished', - line) - if end_match: - step_name = end_match.group(1) - - # Skip install (handled by programmer detection) and sub-steps - if step_name == 'install' or ('-r' in step_name and '-' - in step_name.split('-r')[-1]): - return - - # Skip flush for refine (already flushed during collection) - if step_name.lower() != 'refine': - flush_accumulated_output() - print(f'[Runner] Step finished: {step_name}') - - # If refine step finished, check if it's waiting for input - if step_name.lower() == 'refine': - # Check if there's a waiting input message in recent output - # The refine agent will log "Waiting for user feedback" when should_stop is True - # We'll detect this pattern and mark as waiting for input - # This will be detected by the "Initial refinement completed" pattern above - pass - - # Try to match step name - remove 'programmer-' prefix if needed - if step_name not in self._workflow_steps: - # Try removing 'programmer-' prefix to match actual step name - if step_name.startswith('programmer-'): - base_name = step_name.replace('programmer-', '', 1) - if base_name in self._workflow_steps: - step_name = base_name - else: - # Add the original step name if base name not found - self._workflow_steps.append(step_name) - else: - # Add step if not in list - self._workflow_steps.append(step_name) - - # Build step status dict - all steps up to current are completed - step_status = {} - for s in self._workflow_steps: - step_status[s] = 'completed' if self._workflow_steps.index( - s) <= self._workflow_steps.index(step_name) else 'pending' - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': step_name, - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - # Send step complete message - if self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': step_name, - 'role': 'assistant', - 'metadata': { - 'step': step_name, - 'status': 'completed' - } - }) - - # Clear current step since it's completed - self._current_step = None - return - - # Clean log prefixes from line - # Detect assistant output: "[tag] [assistant]:" - if '[assistant]:' in line: - in_coding = self._current_step and self._current_step.startswith( - 'programmer-') - - if not in_coding: - # Start collecting (don't send first line immediately) - self._accumulated_output = '' - self._collecting_assistant_output = True - # Extract content after [assistant]: if any on same line - parts = line.split('[assistant]:', 1) - if len(parts) > 1 and parts[1].strip(): - content = self._clean_log_prefix(parts[1].strip()) - if content: - self._accumulated_output = content + '\n' - else: - # In coding phase: don't collect - self._collecting_assistant_output = False - self._accumulated_output = '' - # Don't return - continue to process line for file_output detection - - # Continue collecting assistant output - elif self._collecting_assistant_output: - # Skip if in coding phase - if self._current_step and self._current_step.startswith( - 'programmer-'): - self._collecting_assistant_output = False - self._accumulated_output = '' - # Don't return - continue processing - else: - # Check if new pattern starts - if '[tool_calling]:' in line or ('[assistant]:' in line - and 'Agent' not in line): - if self._accumulated_output.strip(): - cleaned = self._clean_log_prefix( - self._accumulated_output.strip()) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step or 'agent' - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - else: - # Accumulate line but also check for deployment URL and waiting_input - if line.strip(): - cleaned_line = self._clean_log_prefix(line) - if cleaned_line: - self._accumulated_output += cleaned_line + '\n' - # Check for EdgeOne deployment URL in this line - url_match = re.search( - r'(https?://[^\s]*edgeone\.cool[^\s]*)', - cleaned_line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace( - '\\&', '&') - print( - f'[Runner] Detected deployment URL in assistant: {deployment_url}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # Check for waiting for input pattern - if ('Waiting for user feedback' in line - or 'Waiting for user input from stdin' - in line): - print('[Runner] Agent waiting for user input') - self._waiting_for_input = True - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': - 'waiting_input', - 'content': - ('✅ Initial refinement completed. ' - 'You can now provide additional feedback or modifications.' - ), - 'role': - 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - return - - # Detect tool calls: "[tag] [tool_calling]:" - if '[tool_calling]:' in line: - self._collecting_tool_call = True - self._current_tool_name = None - self._current_tool_args = None - self._tool_call_json_buffer = '' - - # Check if JSON starts on the same line after [tool_calling]: - parts = line.split('[tool_calling]:', 1) - if len(parts) > 1: - json_part = parts[1].strip() - if json_part.startswith('{'): - self._tool_call_json_buffer = json_part - elif json_part: - # Try to extract tool name directly if it's not JSON format - tool_match = re.search(r'([\w\-]+(?:---[\w\-]+)?)', - json_part) - if tool_match: - self._current_tool_name = tool_match.group(1) - return - - # Continue collecting tool call info - if self._collecting_tool_call: - # Extract agent name from line if available (for better matching) - agent_name_from_line = None - if '[INFO:ms_agent]' in line: - agent_match = re.search(r'\[INFO:ms_agent\]\s*\[([^\]]+)\]', - line) - if agent_match: - agent_name_from_line = agent_match.group(1) - - # Clean log prefixes from line before processing - cleaned_line = self._clean_log_prefix(line) - - # Accumulate JSON lines - if cleaned_line.strip(): - # Remove agent tag prefix if present (e.g., [programmer-config.json]) - cleaned_line = re.sub(r'^\[[^\]]+\]\s*', '', cleaned_line) - - # Skip truncation marker lines (just "...") - if cleaned_line.strip() == '...': - return - - # Skip lines that are just a trailing backslash (truncated escape sequence) - if cleaned_line.strip() == '\\': - return - - if self._tool_call_json_buffer: - self._tool_call_json_buffer += cleaned_line # Don't add newline, keep JSON compact - elif cleaned_line.strip().startswith('{'): - self._tool_call_json_buffer = cleaned_line.strip() - else: - self._tool_call_json_buffer += cleaned_line - - # Only try to parse when buffer contains tool_name and ends with } - if (self._tool_call_json_buffer - and '"tool_name"' in self._tool_call_json_buffer - and self._tool_call_json_buffer.strip().endswith('}')): - try: - import json - tool_info = json.loads(self._tool_call_json_buffer) - print('[Runner] Parsed tool JSON successfully') - tool_name = tool_info.get('tool_name') or tool_info.get( - 'name', 'unknown') - tool_args = tool_info.get('arguments', {}) - print(f'[Runner] Extracted tool_name: {tool_name}') - if tool_name and tool_name != 'unknown': - self._current_tool_name = tool_name - self._current_tool_args = tool_args - agent_name = agent_name_from_line or self._current_step or 'agent' - print( - f'[Runner] Sending tool call: {tool_name}, agent: {agent_name}' - ) - if self.on_output: - self.on_output({ - 'type': 'tool_call', - 'content': f'调用工具: {tool_name}', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'tool_args': tool_args, - 'agent': agent_name - } - }) - # Clear buffer but KEEP collecting - there may be more tool calls - self._tool_call_json_buffer = '' - # Don't return or stop collecting - next line might be another tool call JSON - else: - print( - f'[Runner] WARNING: Invalid tool_name: {tool_name}' - ) - except json.JSONDecodeError as e: - # JSON not complete yet, keep collecting - # Only log if we have tool_name - helps debug parsing issues - if '"tool_name"' in self._tool_call_json_buffer: - print( - f'[Runner] JSON incomplete, continuing... (error: {str(e)[:50]})' - ) - except Exception as e: - print(f'[Runner] Error parsing tool JSON: {e}') - - # Check if we hit a new pattern, stop collecting - if '[assistant]:' in line or 'Agent' in line and 'task' in line or '[tool_result]:' in line: - # If we have partial data, try to send it - if self._tool_call_json_buffer: - tool_name_match = re.search(r'"tool_name"\s*:\s*"([^"]+)"', - self._tool_call_json_buffer) - if tool_name_match: - tool_name = tool_name_match.group(1) - # Try to extract arguments - handle nested JSON objects - args_start = self._tool_call_json_buffer.find( - '"arguments"') - tool_args = {} - if args_start != -1: - brace_start = self._tool_call_json_buffer.find( - '{', args_start) - if brace_start != -1: - brace_count = 0 - brace_end = brace_start - for i in range( - brace_start, - len(self._tool_call_json_buffer)): - if self._tool_call_json_buffer[i] == '{': - brace_count += 1 - elif self._tool_call_json_buffer[i] == '}': - brace_count -= 1 - if brace_count == 0: - brace_end = i + 1 - break - - if brace_end > brace_start: - args_str = self._tool_call_json_buffer[ - brace_start:brace_end] - try: - tool_args = json.loads(args_str) - except Exception: - pass - - # Determine agent name - prefer extracted from line, then current step - agent_name = agent_name_from_line or self._current_step or 'agent' - print( - f'[Runner] Sending tool call (pattern end): ' - f'{tool_name}, agent: {agent_name}, args: {tool_args}' - ) - if self.on_output: - self.on_output({ - 'type': 'tool_call', - 'content': f'调用工具: {tool_name}', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'tool_args': tool_args, - 'agent': agent_name - } - }) - self._collecting_tool_call = False - self._tool_call_json_buffer = '' - return - - # Detect tool results: "[tag] [tool_result]:" - if '[tool_result]:' in line: - self._collecting_tool_result = True - # Extract result content - parts = line.split('[tool_result]:', 1) - if len(parts) > 1: - result_content = parts[1].strip() - if result_content: - self._current_tool_result = result_content - # Send tool result immediately if we have tool name - if self._current_tool_name and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': result_content, - 'agent': self._current_step or 'agent' - } - }) - # Reset tool info - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - return - - # Continue collecting tool result - if self._collecting_tool_result: - # Accumulate result content - if line.strip() and not line.strip().startswith('['): - if self._current_tool_result: - self._current_tool_result += '\n' + line - else: - self._current_tool_result = line - - # Check for EdgeOne deployment URL in tool result - # Pattern 1: JSON format with edgeone.cool or edgeone.site - url_match = re.search( - r'"url":\s*"(https?://[^"]+edgeone\.(cool|site)[^"]+)"', - line) - # Pattern 2: Direct URL with edgeone.cool or edgeone.site - if not url_match: - url_match = re.search( - r'(https?://[^\s]*edgeone\.(cool|site)[^\s]*)', line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace('\\&', '&') - print( - f'[Runner] Detected deployment URL in tool result: {deployment_url}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # After deployment success, prompt user for further input - self._waiting_for_input = True - if not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': - 'You can now provide additional feedback or visit the deployed site.', - 'role': 'system', - 'metadata': { - 'waiting': True, - 'deployment_complete': True - } - }) - self._waiting_input_sent = True - - # Send result if we have tool name and accumulated enough content - if self._current_tool_name and len( - self._current_tool_result) > 100 and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': self._current_tool_result, - 'agent': self._current_step or 'agent' - } - }) - # Reset - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - elif '[assistant]:' in line or '[tool_calling]:' in line or 'Agent' in line and 'task' in line: - # Hit a new pattern, send accumulated result - if self._current_tool_name and self._current_tool_result and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': self._current_tool_result, - 'agent': self._current_step or 'agent' - } - }) - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - return - - # Detect file writing - file_match = re.search(r'writing file:?\s*["\']?([^\s"\']+)["\']?', - line.lower()) - if not file_match: - file_match = re.search( - r'creating file:?\s*["\']?([^\s"\']+)["\']?', line.lower()) - if file_match and self.on_progress: - filename = file_match.group(1) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'writing' - }) - return - - # Detect file written/created/saved - multiple patterns - file_keywords = [ - 'file created', 'file written', 'file saved', 'saved to:', - 'wrote to', 'generated:', 'output:' - ] - if any(keyword in line.lower() for keyword in file_keywords): - # Try to extract filename with extension - # More strict pattern: must have a proper filename with extension, not just numbers - file_match = re.search( - r'["\']?([a-zA-Z0-9_\-][^\s"\'\/\[\]]*\.[a-zA-Z0-9]+)["\']?', - line) - if file_match and self.on_progress: - filename = file_match.group(1) - # Validate filename: must not be just numbers or version numbers like "0.0" - if filename and not re.match(r'^\d+\.\d+$', - filename) and len(filename) > 2: - # Strip 'programmer-' prefix from filename - if filename.startswith('programmer-'): - filename = filename[len('programmer-'):] - print(f'[Runner] Detected file output: {filename}') - # Only send progress update (file_output will be sent from tasks.txt) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'completed' - }) - return - - # Detect output file paths (e.g., "output/user_story.txt" standalone) - output_path_match = re.search( - r'(?:^|\s)((?:output|projects)/[^\s]+\.[a-zA-Z0-9]+)(?:\s|$)', - line) - if output_path_match and self.on_progress: - filename = output_path_match.group(1) - # Strip 'programmer-' prefix from basename only (not from path) - # Split path and filename - if '/' in filename: - parts = filename.rsplit('/', 1) - if len(parts) == 2 and parts[1].startswith('programmer-'): - parts[1] = parts[1][len('programmer-'):] - filename = '/'.join(parts) - elif filename.startswith('programmer-'): - filename = filename[len('programmer-'):] - print(f'[Runner] Detected output path: {filename}') - # Only send progress update (file_output will be sent from tasks.txt) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'completed' - }) - return - - # Deployment URL detection moved to the beginning of _detect_patterns - # to ensure it's always checked before any early returns - - # Detect agent waiting for user input - # Pattern: "✅ Initial refinement completed. You can now provide..." - # Also detect: "Agent completed initial refinement. Waiting for user feedback." - # Also detect: "Waiting for user input from stdin..." - if ('Initial refinement completed' in line - or 'provide additional feedback' in line - or 'Waiting for user feedback' in line - or 'Agent completed initial refinement' in line - or 'Waiting for user input from stdin' in line): - print('[Runner] Agent waiting for user input') - self._waiting_for_input = True # Mark that agent is waiting for input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': - '✅ Initial refinement completed. You can now provide additional feedback or modifications.', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - return diff --git a/webui/backend/api.py b/webui/backend/api.py deleted file mode 100644 index fa68b849b..000000000 --- a/webui/backend/api.py +++ /dev/null @@ -1,806 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -API endpoints for the MS-Agent Web UI -""" -import mimetypes -import os -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse -from pathlib import Path -from pydantic import BaseModel, Field -# Import shared instances -from shared import config_manager, project_discovery, session_manager -from typing import Any, Dict, List, Optional - -router = APIRouter() - - -def get_backend_root() -> Path: - return Path(__file__).resolve().parents[ - 1] # equal to dirname(dirname(__file__)) - - -def get_session_root(session_id: str) -> Path: - if not session_id or not str(session_id).strip(): - raise HTTPException(status_code=400, detail='session_id is required') - - backend_root = get_backend_root() - work_dir = (backend_root / 'work_dir' / str(session_id)).resolve() - work_dir.mkdir(parents=True, exist_ok=True) - return work_dir - - -# Request/Response Models -class ProjectInfo(BaseModel): - id: str - name: str - display_name: str - description: str - type: str # 'workflow' or 'agent' - path: str - has_readme: bool - supports_workflow_switch: bool = False - - -class SessionCreate(BaseModel): - project_id: Optional[str] = None # Optional for chat mode - query: Optional[str] = None - workflow_type: Optional[ - str] = 'standard' # 'standard' or 'simple' for code_genesis - session_type: Optional[str] = 'project' # 'project' or 'chat' - - -class SessionInfo(BaseModel): - id: str - project_id: str - project_name: str - status: str - created_at: str - session_type: Optional[str] = 'project' # 'project' or 'chat' - - -class LLMConfig(BaseModel): - provider: str = 'openai' - model: str = 'qwen3-coder-plus' - api_key: Optional[str] = None - base_url: Optional[str] = None - temperature: Optional[float] = None - temperature_enabled: Optional[bool] = False - max_tokens: Optional[int] = None - - -class EditFileConfig(BaseModel): - api_key: Optional[str] = None - base_url: str = 'https://api.morphllm.com/v1' - diff_model: str = 'morph-v3-fast' - - -class EdgeOnePagesConfig(BaseModel): - api_token: Optional[str] = None - project_name: Optional[str] = None - - -class SearchKeysConfig(BaseModel): - exa_api_key: Optional[str] = None - serpapi_api_key: Optional[str] = None - - -class DeepResearchAgentConfig(BaseModel): - model: Optional[str] = '' - api_key: Optional[str] = '' - base_url: Optional[str] = '' - - -class DeepResearchSearchConfig(BaseModel): - summarizer_model: Optional[str] = '' - summarizer_api_key: Optional[str] = '' - summarizer_base_url: Optional[str] = '' - - -class DeepResearchConfig(BaseModel): - researcher: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - searcher: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - reporter: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - search: DeepResearchSearchConfig = Field( - default_factory=DeepResearchSearchConfig) - - -class MCPServer(BaseModel): - name: str - type: str # 'stdio' or 'sse' - command: Optional[str] = None - args: Optional[List[str]] = None - url: Optional[str] = None - env: Optional[Dict[str, str]] = None - - -class GlobalConfig(BaseModel): - llm: LLMConfig - mcp_servers: Dict[str, Any] - theme: str = 'dark' - output_dir: str = './output' - - -# Project Endpoints -@router.get('/projects', response_model=List[ProjectInfo]) -async def list_projects(): - """List all available projects""" - print( - f'project_discovery.discover_projects(): {project_discovery.discover_projects()}' - ) - return project_discovery.discover_projects() - - -@router.get('/projects/{project_id}') -async def get_project(project_id: str): - """Get detailed information about a specific project""" - project = project_discovery.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - return project - - -@router.get('/projects/{project_id}/readme') -async def get_project_readme(project_id: str): - """Get the README content for a project""" - readme = project_discovery.get_project_readme(project_id) - if readme is None: - raise HTTPException(status_code=404, detail='README not found') - return {'content': readme} - - -@router.get('/projects/{project_id}/workflow') -async def get_project_workflow(project_id: str, - session_id: Optional[str] = None): - """Get the workflow configuration for a project - - If session_id is provided, returns the workflow based on the session's workflow_type. - For code_genesis project, 'simple' workflow_type will return simple_workflow.yaml. - """ - project = project_discovery.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - - # Determine workflow_type from session if session_id is provided - workflow_type = 'standard' # default - if session_id: - session = session_manager.get_session(session_id) - if session and session.get('workflow_type'): - workflow_type = session['workflow_type'] - - # Determine which workflow file to use - if workflow_type == 'simple' and project.get('supports_workflow_switch'): - # For simple workflow, try simple_workflow.yaml first - workflow_file = os.path.join(project['path'], 'simple_workflow.yaml') - if not os.path.exists(workflow_file): - # Fallback to standard workflow.yaml if simple_workflow.yaml doesn't exist - workflow_file = os.path.join(project['path'], 'workflow.yaml') - else: - # Standard workflow - workflow_file = os.path.join(project['path'], 'workflow.yaml') - - if not os.path.exists(workflow_file): - raise HTTPException(status_code=404, detail='Workflow file not found') - - try: - import yaml - with open(workflow_file, 'r', encoding='utf-8') as f: - workflow_data = yaml.safe_load(f) - return {'workflow': workflow_data, 'workflow_type': workflow_type} - except Exception as e: - raise HTTPException( - status_code=500, detail=f'Error reading workflow file: {str(e)}') - - -# Session Endpoints -@router.post('/sessions', response_model=SessionInfo) -async def create_session(session_data: SessionCreate): - """Create a new session for a project or chat mode""" - # Check if this is a chat mode session - if session_data.session_type == 'chat': - # Create chat session without requiring a project - session = session_manager.create_session( - project_id='__chat__', - project_name='Chat Assistant', - workflow_type='standard', - session_type='chat') - return session - - # For project mode, validate project exists - project = project_discovery.get_project(session_data.project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - - # Validate workflow_type for projects that support switching - workflow_type = session_data.workflow_type or 'standard' - if project.get('supports_workflow_switch'): - if workflow_type not in ['standard', 'simple']: - raise HTTPException( - status_code=400, - detail="workflow_type must be 'standard' or 'simple'") - - session = session_manager.create_session( - project_id=session_data.project_id, - project_name=project['name'], - workflow_type=workflow_type, - session_type='project') - return session - - -@router.get('/sessions', response_model=List[SessionInfo]) -async def list_sessions(): - """List all active sessions""" - return session_manager.list_sessions() - - -@router.get('/sessions/{session_id}') -async def get_session(session_id: str): - """Get session details""" - session = session_manager.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail='Session not found') - return session - - -@router.delete('/sessions/{session_id}') -async def delete_session(session_id: str): - """Delete a session""" - success = session_manager.delete_session(session_id) - if not success: - raise HTTPException(status_code=404, detail='Session not found') - return {'status': 'deleted'} - - -@router.get('/sessions/{session_id}/messages') -async def get_session_messages(session_id: str): - """Get all messages for a session""" - messages = session_manager.get_messages(session_id) - if messages is None: - raise HTTPException(status_code=404, detail='Session not found') - return {'messages': messages} - - -@router.get('/sessions/{session_id}/dr_events') -async def get_session_dr_events(session_id: str, - after_id: Optional[int] = Query(None, ge=0)): - """Get deep research event history for a session.""" - events = session_manager.list_dr_events(session_id, after_id) - if events is None: - raise HTTPException(status_code=404, detail='Session not found') - return {'events': events} - - -# Configuration Endpoints -@router.get('/config') -async def get_config(): - """Get global configuration""" - return config_manager.get_config() - - -@router.put('/config') -async def update_config(config: GlobalConfig): - """Update global configuration""" - config_manager.update_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/llm') -async def get_llm_config(): - """Get LLM configuration""" - return config_manager.get_llm_config() - - -@router.put('/config/llm') -async def update_llm_config(config: LLMConfig): - """Update LLM configuration""" - config_manager.update_llm_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/mcp') -async def get_mcp_config(): - """Get MCP servers configuration""" - return config_manager.get_mcp_config() - - -@router.put('/config/mcp') -async def update_mcp_config(servers: Dict[str, Any]): - """Update MCP servers configuration""" - config_manager.update_mcp_config(servers) - return {'status': 'updated'} - - -@router.get('/config/edit_file') -async def get_edit_file_config(): - """Get edit_file_config configuration""" - return config_manager.get_edit_file_config() - - -@router.put('/config/edit_file') -async def update_edit_file_config(config: EditFileConfig): - """Update edit_file_config configuration""" - config_manager.update_edit_file_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/edgeone_pages') -async def get_edgeone_pages_config(): - """Get EdgeOne Pages configuration""" - return config_manager.get_edgeone_pages_config() - - -@router.put('/config/edgeone_pages') -async def update_edgeone_pages_config(config: EdgeOnePagesConfig): - """Update EdgeOne Pages configuration""" - config_manager.update_edgeone_pages_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/search_keys') -async def get_search_keys_config(): - """Get search API keys configuration""" - return config_manager.get_search_keys() - - -@router.put('/config/search_keys') -async def update_search_keys_config(config: SearchKeysConfig): - """Update search API keys configuration""" - config_manager.update_search_keys(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/deep_research') -async def get_deep_research_config(): - """Get deep research configuration""" - return config_manager.get_deep_research_config() - - -@router.put('/config/deep_research') -async def update_deep_research_config(config: DeepResearchConfig): - """Update deep research configuration""" - config_manager.update_deep_research_config(config.model_dump()) - return {'status': 'updated'} - - -@router.post('/config/mcp/servers') -async def add_mcp_server(server: MCPServer): - """Add a new MCP server""" - config_manager.add_mcp_server(server.name, - server.model_dump(exclude={'name'})) - return {'status': 'added'} - - -@router.delete('/config/mcp/servers/{server_name}') -async def remove_mcp_server(server_name: str): - """Remove an MCP server""" - success = config_manager.remove_mcp_server(server_name) - if not success: - raise HTTPException(status_code=404, detail='Server not found') - return {'status': 'removed'} - - -# Available models endpoint -@router.get('/models') -async def list_available_models(): - """List available LLM models""" - return { - 'models': [ - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen3-235B-A22B-Instruct-2507', - 'display_name': 'Qwen3-235B (Recommended)' - }, - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen2.5-72B-Instruct', - 'display_name': 'Qwen2.5-72B' - }, - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen2.5-32B-Instruct', - 'display_name': 'Qwen2.5-32B' - }, - { - 'provider': 'modelscope', - 'model': 'deepseek-ai/DeepSeek-V3', - 'display_name': 'DeepSeek-V3' - }, - { - 'provider': 'openai', - 'model': 'gpt-4o', - 'display_name': 'GPT-4o' - }, - { - 'provider': 'openai', - 'model': 'gpt-4o-mini', - 'display_name': 'GPT-4o Mini' - }, - { - 'provider': 'anthropic', - 'model': 'claude-3-5-sonnet-20241022', - 'display_name': 'Claude 3.5 Sonnet' - }, - ] - } - - -# File content endpoint -class FileReadRequest(BaseModel): - path: str - session_id: Optional[str] = None - root_dir: Optional[str] = None - - -@router.get('/files/list') -async def list_output_files( - output_dir: Optional[str] = Query(default='output'), - session_id: Optional[str] = Query(default=None), - root_dir: Optional[str] = Query(default=None), -): - """List all files under root_dir as a tree structure. - root_dir: optional. If not provided, defaults to ms-agent/output. - Also supports 'projects' or 'projects/xxx' etc. - """ - # Excluded folders - exclude_dirs = { - 'node_modules', '__pycache__', '.git', '.venv', 'venv', 'dist', 'build' - } - - # Base directories (same way as read_file_content) - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - projects_dir = os.path.join(base_dir, 'projects') - - if session_id: - - session_root = get_session_root(session_id) - resolved_root = (session_root / '').resolve() - - elif not root_dir or root_dir.strip() == '': - resolved_root = output_dir - else: - root_dir = root_dir.strip() - - # If absolute, use as-is - if os.path.isabs(root_dir): - resolved_root = root_dir - # If starts with 'projects/', join with base_dir - elif root_dir.startswith('projects/'): - resolved_root = os.path.join(base_dir, root_dir) - else: - # Try relative to output first, then projects - cand1 = os.path.join(output_dir, root_dir) - cand2 = os.path.join(projects_dir, root_dir) - - if os.path.exists(cand1): - resolved_root = cand1 - elif os.path.exists(cand2): - resolved_root = cand2 - else: - # If user passes "output" or "projects" explicitly - if root_dir in ('output', 'output/'): - resolved_root = output_dir - elif root_dir in ('projects', 'projects/'): - resolved_root = projects_dir - else: - # fall back to output + root_dir (but it likely doesn't exist) - resolved_root = cand1 - - resolved_root = os.path.normpath(os.path.abspath(resolved_root)) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `resolved_root` is within configured allowed roots. - - def build_tree(dir_path: str) -> dict: - result = {'folders': {}, 'files': []} - - if not os.path.exists(dir_path): - return result - - try: - items = os.listdir(dir_path) - except PermissionError: - return result - - for item in sorted(items): - if item.startswith('.') or item in exclude_dirs: - continue - - full_path = os.path.join(dir_path, item) - - if os.path.isdir(full_path): - subtree = build_tree(full_path) - if subtree['folders'] or subtree['files']: - result['folders'][item] = subtree - else: - # Return RELATIVE path to resolved_root (better for frontend + read API) - rel_path = os.path.relpath(full_path, resolved_root) - - result['files'].append({ - 'name': item, - 'path': rel_path, # <-- relative path - 'abs_path': - full_path, # optional: if you still want absolute for debugging - 'size': os.path.getsize(full_path), - 'modified': os.path.getmtime(full_path) - }) - - result['files'].sort(key=lambda x: x['modified'], reverse=True) - return result - - print('resolved_root =', resolved_root) - tree = build_tree(resolved_root) - return {'tree': tree, 'root_dir': resolved_root} - - -def get_allowed_roots(): - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - output_dir = os.path.join(base_dir, 'output') - projects_dir = os.path.join(base_dir, 'projects') - return base_dir, os.path.normpath(output_dir), os.path.normpath( - projects_dir) - - -def resolve_root_dir(root_dir: Optional[str]) -> str: - """ - Resolve optional root_dir to an absolute normalized path within allowed roots. - Default: output_dir - Supports: - - None/"" => output_dir - - "output", "projects", "projects/xxx" - - absolute path (must still be under allowed roots) - """ - _, output_dir, projects_dir = get_allowed_roots() - - if not root_dir or root_dir.strip() == '': - resolved = output_dir - else: - rd = root_dir.strip() - - if os.path.isabs(rd): - resolved = rd - else: - # Allow explicit "output"/"projects" - if rd in ('output', 'output/'): - resolved = output_dir - elif rd in ('projects', 'projects/'): - resolved = projects_dir - else: - cand1 = os.path.join(output_dir, rd) - cand2 = os.path.join(projects_dir, rd) - # choose existing one if possible, otherwise default to cand1 - resolved = cand1 if os.path.exists(cand1) else ( - cand2 if os.path.exists(cand2) else cand1) - - resolved = os.path.normpath(os.path.abspath(resolved)) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `resolved` is within configured allowed roots. - - return resolved - - -def resolve_file_path(root_dir_abs: str, file_path: str) -> str: - """ - Resolve file_path against root_dir_abs. - - if file_path starts with 'projects/', resolve from ms-agent base dir - - if file_path is absolute, use as-is - - if relative, join(root_dir_abs, file_path) - """ - root_dir_abs = os.path.normpath(os.path.abspath(root_dir_abs)) - - if os.path.isabs(file_path): - full_path = os.path.normpath(os.path.abspath(file_path)) - elif file_path.startswith('projects/'): - # Special case: if path starts with 'projects/', resolve from base_dir - # This handles: projects/code_genesis/output/config.js - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - full_path = os.path.normpath( - os.path.abspath(os.path.join(base_dir, file_path))) - else: - # Try multiple locations - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - candidates = [ - # First try with root_dir_abs (for session-based access) - os.path.join(root_dir_abs, file_path), - # Then try in each project's output directory - ] - - # Search in project output directories - projects_dir = os.path.join(base_dir, 'projects') - if os.path.exists(projects_dir): - try: - for project_name in os.listdir(projects_dir): - project_path = os.path.join(projects_dir, project_name) - if os.path.isdir(project_path): - candidates.append( - os.path.join(project_path, 'output', file_path)) - except (OSError, PermissionError): - pass - - # Find first existing file - full_path = None - for candidate in candidates: - candidate = os.path.normpath(candidate) - if os.path.exists(candidate) and os.path.isfile(candidate): - full_path = candidate - break - - if not full_path: - # Default to first candidate if none found - full_path = os.path.normpath(candidates[0]) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `full_path` is within configured allowed roots. - - return full_path - - -@router.post('/files/read') -async def read_file_content(request: FileReadRequest): - if request.session_id: - session_root = get_session_root(request.session_id) - root_abs = os.path.normpath(os.path.abspath(str(session_root))) - else: - root_abs = resolve_root_dir(request.root_dir) - full_path = resolve_file_path(root_abs, request.path) - - if not os.path.exists(full_path): - raise HTTPException( - status_code=404, detail=f'File not found: {full_path}') - - if not os.path.isfile(full_path): - raise HTTPException( - status_code=400, detail=f'Path {full_path} is not a file') - # limit 1MB - file_size = os.path.getsize(full_path) - if file_size > 1024 * 1024: - raise HTTPException(status_code=400, detail='File too large (max 1MB)') - - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - - ext = os.path.splitext(full_path)[1].lower() - lang_map = { - '.py': 'python', - '.js': 'javascript', - '.ts': 'typescript', - '.tsx': 'typescript', - '.jsx': 'javascript', - '.json': 'json', - '.yaml': 'yaml', - '.yml': 'yaml', - '.md': 'markdown', - '.html': 'html', - '.css': 'css', - '.txt': 'text', - '.sh': 'bash', - '.java': 'java', - '.go': 'go', - '.rs': 'rust', - } - language = lang_map.get(ext, 'text') - - # Return a relative path (relative to root_dir) for consistent handling on the frontend. - rel_path = os.path.relpath(full_path, root_abs) - - return { - 'content': content, - 'path': rel_path, - 'abs_path': full_path, - 'root_dir': root_abs, - 'filename': os.path.basename(full_path), - 'language': language, - 'size': file_size - } - except UnicodeDecodeError: - raise HTTPException(status_code=400, detail='File is not a text file') - except Exception as e: - raise HTTPException( - status_code=500, detail=f'Error reading file: {str(e)}') - - -def resolve_and_check_path(file_path: str) -> str: - """Resolve file path, trying multiple locations""" - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - if os.path.isabs(file_path): - full_path = file_path - else: - # Smart path resolution: - # If path already starts with 'projects/', use it directly under base_dir - # Otherwise try output_dir first, then search in project outputs - - candidates = [] - - # If path starts with 'projects/', join directly with base_dir - if file_path.startswith('projects/'): - candidates.append(os.path.join(base_dir, file_path)) - else: - # Try base_dir/output first - output_dir = os.path.join(base_dir, 'output') - candidates.append(os.path.join(output_dir, file_path)) - - # Try base_dir directly - candidates.append(os.path.join(base_dir, file_path)) - - # Search in each project's output directory - projects_dir = os.path.join(base_dir, 'projects') - if os.path.exists(projects_dir): - try: - for project_name in os.listdir(projects_dir): - project_path = os.path.join(projects_dir, project_name) - if os.path.isdir(project_path): - # Try project/output/filename - candidates.append( - os.path.join(project_path, 'output', - file_path)) - except (OSError, PermissionError): - pass - - # Find first existing file - full_path = None - for candidate in candidates: - candidate = os.path.normpath(candidate) - if os.path.exists(candidate) and os.path.isfile(candidate): - full_path = candidate - break - - if not full_path: - # If not found, use the first candidate for error message - full_path = os.path.normpath( - candidates[0] if candidates else file_path) - - full_path = os.path.normpath(full_path) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `full_path` is within configured allowed roots. - - if not os.path.exists(full_path): - raise HTTPException( - status_code=404, detail=f'File not found: {full_path}') - if not os.path.isfile(full_path): - raise HTTPException( - status_code=400, detail=f'Path {full_path} is not a file') - - return full_path - - -@router.get('/files/stream') -async def stream_file(path: str, - session_id: Optional[str] = Query(default=None)): - if session_id: - session_root = get_session_root(session_id) - root_abs = str(session_root.resolve()) - full_path = resolve_file_path(root_abs, path) - else: - full_path = resolve_and_check_path(path) - - media_type, _ = mimetypes.guess_type(full_path) - media_type = media_type or 'application/octet-stream' - return FileResponse( - full_path, - media_type=media_type, - filename=os.path.basename(full_path), - headers={ - 'Content-Disposition': - f'inline; filename="{os.path.basename(full_path)}"' - }, - ) diff --git a/webui/backend/app/__init__.py b/webui/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/api/__init__.py b/webui/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/api/agent_settings.py b/webui/backend/app/api/agent_settings.py new file mode 100644 index 000000000..97ce42fe1 --- /dev/null +++ b/webui/backend/app/api/agent_settings.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from app.core.envelope import EnvelopeRoute +from app.schemas.agent_settings import AgentSettings + +router = APIRouter(prefix="/api/agent-settings", tags=["agent-settings"], + route_class=EnvelopeRoute) + + +@router.get("") +def get_settings() -> AgentSettings: + from app.backends.ms_agent import agent_settings + + return agent_settings.get_settings() + + +@router.put("") +def update_settings(body: AgentSettings) -> AgentSettings: + from app.backends.ms_agent import agent_settings + + return agent_settings.update_settings(body) diff --git a/webui/backend/app/api/chat.py b/webui/backend/app/api/chat.py new file mode 100644 index 000000000..798164007 --- /dev/null +++ b/webui/backend/app/api/chat.py @@ -0,0 +1,82 @@ +from typing import Literal + +from fastapi import APIRouter +from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse + +from app.backends import get_backend +from app.core.envelope import EnvelopeRoute +from app.schemas.chat import ChatRequest + +router = APIRouter(prefix="/api/chat", tags=["chat"], route_class=EnvelopeRoute) + + +@router.post("") +async def chat(req: ChatRequest): + # @ant-design/x-sdk's XStream splits SSE frames on "\n\n". sse-starlette + # defaults to CRLF, which causes the browser client to merge frames and drop + # intermediate deltas. Emit LF-separated events for this WebUI contract. + return EventSourceResponse(get_backend().chat_stream(req), sep="\n") + + +class ChatAttach(BaseModel): + """Re-attach a viewer to a session's in-flight turn: replays the turn's + events so far (catch-up) and follows the live tail — same ChatChunk SSE as + POST /api/chat. Used when the user navigates back to a session whose turn + kept running in the background. Emits just `done` when nothing is running.""" + + session_id: str + + +@router.post("/attach") +async def chat_attach(body: ChatAttach): + return EventSourceResponse( + get_backend().chat_attach(body.session_id), sep="\n" + ) + + +class PermissionResolve(BaseModel): + """Answer to a restricted-mode authorization card (step kind + "authorization"): the SSE turn is suspended on this request_id until it is + resolved here or the backend times out to deny.""" + + session_id: str + request_id: str + action: Literal["allow_once", "allow_always", "deny"] + + +@router.post("/permission") +async def resolve_permission(body: PermissionResolve) -> dict: + from app.backends.ms_agent.runtime import registry + + resolved = registry.resolve_permission( + body.session_id, body.request_id, body.action + ) + return {"resolved": resolved} + + +class ChatInterrupt(BaseModel): + """Explicit stop of a session's in-flight turn (the composer Stop button). + + This is distinct from merely closing the SSE (navigating away): a bare + disconnect keeps the turn running in the background, so leaving a + conversation does not stop it and other sessions run concurrently. Only this + call cancels the turn and seals it with an interrupted marker.""" + + session_id: str + + +@router.post("/interrupt") +async def interrupt_chat(body: ChatInterrupt) -> dict: + from app.backends.ms_agent.runtime import registry + + stopped = await registry.interrupt(body.session_id) + return {"stopped": stopped} + + +# NOTE: the former POST /api/chat/cancel (pagehide sendBeacon) was removed. +# pagehide also fires on a page REFRESH, so beacon-cancelling killed turns the +# user expected to survive. Product decision (aligned with the frontend team): +# a running turn is NEVER stopped by clients going away — navigation, refresh +# and a fully closed browser all leave it running to completion in the +# background. Only the explicit Stop (POST /api/chat/interrupt) cancels. diff --git a/webui/backend/app/api/instructions.py b/webui/backend/app/api/instructions.py new file mode 100644 index 000000000..d9ecbccc3 --- /dev/null +++ b/webui/backend/app/api/instructions.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.instruction import Instruction, InstructionUpsert + +router = APIRouter(prefix="/api/instructions", tags=["instructions"], + route_class=EnvelopeRoute) + + +@router.get("") +def get_instruction(scope: str) -> Instruction: + from app.backends.ms_agent import instructions + + return instructions.get_instruction(scope) + + +@router.put("") +def upsert_instruction(scope: str, body: InstructionUpsert) -> Instruction: + from app.backends.ms_agent import instructions + + return instructions.upsert_instruction(scope, body) diff --git a/webui/backend/app/api/mcps.py b/webui/backend/app/api/mcps.py new file mode 100644 index 000000000..8e72dd11f --- /dev/null +++ b/webui/backend/app/api/mcps.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.mcp import Mcp, McpCreate, McpHealth, McpReplace, McpUpdate + +router = APIRouter(prefix="/api/mcps", tags=["mcps"], route_class=EnvelopeRoute) + + +@router.get("") +def list_mcps(scope: str | None = None) -> list[Mcp]: + from app.backends.ms_agent import mcps + + return mcps.list_mcps(scope) + + +@router.get("/health") +def mcps_health() -> list[McpHealth]: + """Live reachability of enabled MCP servers (on-demand connect+initialize). + Defined before /{mcp_id} so the literal path wins over the id capture.""" + from app.backends.ms_agent import mcps + + return mcps.health() + + +@router.get("/{mcp_id}/health") +def mcp_health_check(mcp_id: str) -> McpHealth: + """Probe a single MCP server's connectivity. Returns healthy + error reason.""" + from app.backends.ms_agent import mcps + + return mcps.health_one(mcp_id) + + +@router.put("") +def replace_mcps(body: McpReplace) -> list[Mcp]: + """Replace one scope's servers with exactly `body.servers`, in that order. + + The raw-JSON editor saves a whole document, so it needs one atomic call: the + per-item delete+create dance it used before could half-apply. + """ + from app.backends.ms_agent import mcps + + return mcps.replace_mcps(body.scope, body.servers) + + +@router.post("", status_code=201) +def create_mcp(body: McpCreate) -> Mcp: + from app.backends.ms_agent import mcps + + return mcps.create_mcp(body) + + +@router.get("/{mcp_id}") +def get_mcp(mcp_id: str) -> Mcp: + from app.backends.ms_agent import mcps + + return mcps.get_mcp(mcp_id) + + +@router.patch("/{mcp_id}") +def update_mcp(mcp_id: str, body: McpUpdate) -> Mcp: + from app.backends.ms_agent import mcps + + return mcps.update_mcp(mcp_id, body) + + +@router.delete("/{mcp_id}", status_code=204) +def delete_mcp(mcp_id: str) -> None: + from app.backends.ms_agent import mcps + + return mcps.delete_mcp(mcp_id) diff --git a/webui/backend/app/api/memory.py b/webui/backend/app/api/memory.py new file mode 100644 index 000000000..9f3ac28e1 --- /dev/null +++ b/webui/backend/app/api/memory.py @@ -0,0 +1,87 @@ +"""Project-scoped memory items. + +Memory is gated per-project: every route 404s for an unknown project and 400s +when the project has `memory_enabled=False`. The default project is not special +here — it simply ships with memory off. +""" + +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.memory import ( + MemoryDoc, + MemoryDocUpdate, + MemoryItem, + MemoryItemCreate, + MemoryItemUpdate, + MemoryStatus, +) + +router = APIRouter(prefix="/api/projects/{project_id}/memory", tags=["memory"], + route_class=EnvelopeRoute) + + +@router.get("/status") +def get_status(project_id: str) -> MemoryStatus: + """Health of the project's memory: the resolved embedder identity, why + vector memory is unusable if it is, and the last ingest outcome.""" + from app.backends.ms_agent import memory + + return memory.get_status(project_id) + + +@router.post("/rebuild") +async def rebuild(project_id: str) -> MemoryStatus: + """Start the vector store over with the current embedder (the remedy for + an embedder mismatch). The old store is moved to qdrant.bak-.""" + from app.backends.ms_agent import memory + + return await memory.rebuild(project_id) + + +@router.get("/items") +async def list_items(project_id: str) -> list[MemoryItem]: + from app.backends.ms_agent import memory + + return await memory.list_items(project_id) + + +@router.post("/items", status_code=201) +def create_item(project_id: str, body: MemoryItemCreate) -> MemoryItem: + from app.backends.ms_agent import memory + + return memory.create_item(project_id, body) + + +@router.put("/items/{item_id}") +def update_item(project_id: str, item_id: str, + body: MemoryItemUpdate) -> MemoryItem: + from app.backends.ms_agent import memory + + return memory.update_item(project_id, item_id, body) + + +@router.delete("/items/{item_id}", status_code=204) +async def delete_item(project_id: str, item_id: str) -> None: + from app.backends.ms_agent import memory + + return await memory.delete_item(project_id, item_id) + + +# ── file backend only: memory as one markdown document ───────────────────── +# `memory_backend="file"` stores memory as a single MEMORY.md the agent reads, +# so the UI previews/edits it as a document. 400 for vector projects. + + +@router.get("/doc") +def get_doc(project_id: str) -> MemoryDoc: + from app.backends.ms_agent import memory + + return memory.get_doc(project_id) + + +@router.put("/doc") +def put_doc(project_id: str, body: MemoryDocUpdate) -> MemoryDoc: + from app.backends.ms_agent import memory + + return memory.put_doc(project_id, body) diff --git a/webui/backend/app/api/models.py b/webui/backend/app/api/models.py new file mode 100644 index 000000000..f5767e7c0 --- /dev/null +++ b/webui/backend/app/api/models.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.model import Model, ModelCreate, ModelUpdate + +router = APIRouter(prefix="/api/models", tags=["models"], + route_class=EnvelopeRoute) + + +@router.get("") +def list_models(provider_id: str | None = None) -> list[Model]: + from app.backends.ms_agent import models + + return models.list_models(provider_id) + + +@router.post("", status_code=201) +def create_model(body: ModelCreate) -> Model: + from app.backends.ms_agent import models + + return models.create_model(body) + + +@router.patch("/{model_id}") +def update_model(model_id: str, body: ModelUpdate) -> Model: + from app.backends.ms_agent import models + + return models.update_model(model_id, body) + + +@router.delete("/{model_id}", status_code=204) +def delete_model(model_id: str) -> None: + from app.backends.ms_agent import models + + return models.delete_model(model_id) diff --git a/webui/backend/app/api/presence.py b/webui/backend/app/api/presence.py new file mode 100644 index 000000000..70e92c85d --- /dev/null +++ b/webui/backend/app/api/presence.py @@ -0,0 +1,25 @@ +"""Running-state poll for the app shell. + +The frontend polls here every ~10s. The response carries the ids of sessions +with a turn in flight, which drives the sidebar "running" spinners, triggers +the live re-attach when the user opens a running session, and lets the client +reload lists/history the moment a background turn finishes. + +This is NOT a liveness contract: by product decision (aligned with the +frontend team), a running turn is never stopped because clients went away — +navigation, refresh and even a fully closed browser all leave it running to +completion in the background. Only the explicit Stop button +(POST /api/chat/interrupt) cancels a turn. +""" +from fastapi import APIRouter + +from app.core.envelope import EnvelopeRoute + +router = APIRouter(prefix="/api", tags=["presence"], route_class=EnvelopeRoute) + + +@router.post("/presence") +async def presence() -> dict: + from app.backends.ms_agent.runtime import registry + + return {"running": registry.running_sessions()} diff --git a/webui/backend/app/api/profile.py b/webui/backend/app/api/profile.py new file mode 100644 index 000000000..005d74fdb --- /dev/null +++ b/webui/backend/app/api/profile.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from app.core.envelope import EnvelopeRoute +from app.schemas.profile import Profile, ProfileUpsert + +router = APIRouter(prefix="/api/profile", tags=["profile"], + route_class=EnvelopeRoute) + + +@router.get("") +def get_profile() -> Profile: + from app.backends.ms_agent import profile + + return profile.get_profile() + + +@router.put("") +def update_profile(body: ProfileUpsert) -> Profile: + from app.backends.ms_agent import profile + + return profile.update_profile(body) diff --git a/webui/backend/app/api/projects.py b/webui/backend/app/api/projects.py new file mode 100644 index 000000000..904e67001 --- /dev/null +++ b/webui/backend/app/api/projects.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.project import Project, ProjectCreate, ProjectUpdate + +router = APIRouter(prefix="/api/projects", tags=["projects"], + route_class=EnvelopeRoute) + + +@router.get("") +def list_projects() -> list[Project]: + from app.backends.ms_agent import projects + + return projects.list_projects() + + +@router.post("", status_code=201) +def create_project(body: ProjectCreate) -> Project: + from app.backends.ms_agent import projects + + return projects.create_project(body) + + +@router.get("/{project_id}") +def get_project(project_id: str) -> Project: + from app.backends.ms_agent import projects + + return projects.get_project(project_id) + + +@router.patch("/{project_id}") +def update_project(project_id: str, body: ProjectUpdate) -> Project: + from app.backends.ms_agent import projects + + return projects.update_project(project_id, body) + + +@router.delete("/{project_id}", status_code=204) +def delete_project(project_id: str) -> None: + from app.backends.ms_agent import projects + + return projects.delete_project(project_id) diff --git a/webui/backend/app/api/providers.py b/webui/backend/app/api/providers.py new file mode 100644 index 000000000..51f2fd648 --- /dev/null +++ b/webui/backend/app/api/providers.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.provider import Provider, ProviderCreate, ProviderUpdate + +router = APIRouter(prefix="/api/providers", tags=["providers"], + route_class=EnvelopeRoute) + + +@router.get("") +def list_providers() -> list[Provider]: + from app.backends.ms_agent import providers + + return providers.list_providers() + + +@router.post("", status_code=201) +def create_provider(body: ProviderCreate) -> Provider: + from app.backends.ms_agent import providers + + return providers.create_provider(body) + + +@router.get("/{provider_id}") +def get_provider(provider_id: str) -> Provider: + from app.backends.ms_agent import providers + + return providers.get_provider(provider_id) + + +@router.patch("/{provider_id}") +def update_provider(provider_id: str, body: ProviderUpdate) -> Provider: + from app.backends.ms_agent import providers + + return providers.update_provider(provider_id, body) + + +@router.delete("/{provider_id}", status_code=204) +def delete_provider(provider_id: str) -> None: + from app.backends.ms_agent import providers + + return providers.delete_provider(provider_id) + + +@router.get("/{provider_id}/available-models") +def available_models(provider_id: str) -> list[str]: + """Best-effort model-id discovery via the provider's standard /models + endpoint. Returns [] on any failure (missing key, network error, etc.).""" + from app.core.model_discovery import fetch_model_ids + + from app.backends.ms_agent import providers + + base_url, protocol, api_key = providers.get_provider_secret( + provider_id) + # fetch_model_ids is itself best-effort (missing key, network error, non-2xx + # and non-standard payloads all degrade to []), so its result is returned + # as-is: the UI falls back to free-form manual entry on an empty list. + return fetch_model_ids(base_url, protocol, api_key) diff --git a/webui/backend/app/api/sessions.py b/webui/backend/app/api/sessions.py new file mode 100644 index 000000000..5e2491d6a --- /dev/null +++ b/webui/backend/app/api/sessions.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.session import ( + Artifact, Session, SessionCreate, SessionMessage, SessionPlan, SessionUpdate +) + +router = APIRouter(prefix="/api", tags=["sessions"], route_class=EnvelopeRoute) + + +@router.get("/sessions") +def list_sessions(project_id: str | None = None) -> list[Session]: + from app.backends.ms_agent import sessions + + return sessions.list_sessions(project_id) + + +@router.post("/sessions", status_code=201) +def create_session(body: SessionCreate) -> Session: + from app.backends.ms_agent import sessions + + return sessions.create_session(body) + + +@router.get("/sessions/{session_id}") +def get_session(session_id: str) -> Session: + from app.backends.ms_agent import sessions + + return sessions.get_session(session_id) + + +@router.get("/sessions/{session_id}/messages") +def list_session_messages(session_id: str) -> list[SessionMessage]: + from app.backends.ms_agent import sessions + + return sessions.list_messages(session_id) + + +@router.get("/sessions/{session_id}/plan") +def get_session_plan(session_id: str) -> SessionPlan: + """The latest plan.json for the session, plus whether it belongs to the + CURRENT running turn (``active`` — the server-side truth the composer uses + to animate running rows). Always reflects the live plan file (tool writes + + manual edits) and ignores the chat/session log.""" + from app.backends.ms_agent import sessions + + return sessions.read_plan(session_id) + + +@router.delete("/sessions/{session_id}", status_code=204) +def delete_session(session_id: str) -> None: + from app.backends.ms_agent import sessions + + return sessions.delete_session(session_id) + + +@router.patch("/sessions/{session_id}") +def update_session(session_id: str, body: SessionUpdate) -> Session: + """Rename a session (update its title).""" + from app.backends.ms_agent import sessions + + return sessions.update_session(session_id, body) + + +@router.get("/sessions/{session_id}/artifacts") +def list_artifacts(session_id: str) -> list[Artifact]: + from app.backends.ms_agent import sessions + + return sessions.list_artifacts(session_id) diff --git a/webui/backend/app/api/skills.py b/webui/backend/app/api/skills.py new file mode 100644 index 000000000..d530f14ef --- /dev/null +++ b/webui/backend/app/api/skills.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException + +from app.core.envelope import EnvelopeRoute +from app.schemas.skill import ( + Skill, + SkillCreate, + SkillFile, + SkillFileContent, + SkillUpdate, +) + +router = APIRouter(prefix="/api/skills", tags=["skills"], + route_class=EnvelopeRoute) + + +@router.get("") +def list_skills(scope: str | None = None) -> list[Skill]: + from app.backends.ms_agent import skills + + return skills.list_skills(scope) + + +@router.post("", status_code=201) +def create_skill(body: SkillCreate) -> Skill: + from app.backends.ms_agent import skills + + return skills.create_skill(body) + + +@router.get("/{skill_id}") +def get_skill(skill_id: str) -> Skill: + from app.backends.ms_agent import skills + + return skills.get_skill(skill_id) + + +@router.get("/{skill_id}/files") +def list_skill_files(skill_id: str) -> list[SkillFile]: + """Real file listing of the skill's on-disk directory (viewer tree).""" + from app.backends.ms_agent import skills + + return skills.list_skill_files(skill_id) + + +@router.get("/{skill_id}/file") +def read_skill_file(skill_id: str, path: str) -> SkillFileContent: + """UTF-8 content of one skill file; ``content=null`` marks binary.""" + from app.backends.ms_agent import skills + + return skills.read_skill_file(skill_id, path) + + +@router.patch("/{skill_id}") +def update_skill(skill_id: str, body: SkillUpdate) -> Skill: + from app.backends.ms_agent import skills + + return skills.update_skill(skill_id, body) + + +@router.delete("/{skill_id}", status_code=204) +def delete_skill(skill_id: str) -> None: + from app.backends.ms_agent import skills + + return skills.delete_skill(skill_id) diff --git a/webui/backend/app/api/workspace.py b/webui/backend/app/api/workspace.py new file mode 100644 index 000000000..b558aafc2 --- /dev/null +++ b/webui/backend/app/api/workspace.py @@ -0,0 +1,101 @@ +"""Project-scoped workspace files. + +Iter-3 scope: real CRUD against an in-memory mock so the UI can stop using the +hard-coded tree in SessionRightRail. Upload / download / Import are stubbed — +the frontend exposes the affordances but they POST/PUT plain JSON through +this same surface. +""" + +import time + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import Response + +from app.core.envelope import EnvelopeRoute +from app.core.filetypes import guess_type, is_binary_ext +from app.schemas.workspace import ( + WorkspaceFile, + WorkspaceFileCreate, + WorkspaceFileMove, + WorkspaceFileUpdate, +) + +router = APIRouter( + prefix="/api/projects/{project_id}/workspace", + tags=["workspace"], + route_class=EnvelopeRoute, +) + + +@router.get("/files") +def list_files(project_id: str) -> list[WorkspaceFile]: + from app.backends.ms_agent import workspace + + return workspace.list_files(project_id) + + +@router.post("/files", status_code=201) +def create_file(project_id: str, body: WorkspaceFileCreate) -> WorkspaceFile: + from app.backends.ms_agent import workspace + + return workspace.create_file(project_id, body) + + +@router.post("/files/move") +def move_file(project_id: str, body: WorkspaceFileMove) -> WorkspaceFile: + """Rename/move a file or folder. Folder moves rewrite every child path.""" + from app.backends.ms_agent import workspace + + return workspace.move_file(project_id, body.src, body.dst) + + +@router.get("/files/{file_path:path}/raw") +def raw_file(project_id: str, file_path: str) -> Response: + """Serve raw file bytes (for media preview / download). Not enveloped: the + EnvelopeRoute only wraps JSON responses, so binary passes through as-is.""" + from app.backends.ms_agent import workspace + + target, ctype = workspace.raw_file(project_id, file_path) + return Response(content=target.read_bytes(), media_type=ctype) + + +@router.post("/files/upload", status_code=201) +async def upload_file( + project_id: str, + file: UploadFile = File(...), + path: str | None = Form(None), + dedup: bool = Form(False), +) -> WorkspaceFile: + """Binary-safe upload via multipart/form-data. Raw bytes are written to disk + unchanged. A same-path file is overwritten by default; with ``dedup`` (chat + attachments into ``user_files/``) a same-named-but-different file is + auto-suffixed and the returned ``path`` is the real, deduped location.""" + rel = (path or file.filename or "").strip() + if not rel: + raise HTTPException(422, "missing file path") + data = await file.read() + from app.backends.ms_agent import workspace + + return workspace.save_upload(project_id, rel, data, dedup=dedup) + + +@router.get("/files/{file_path:path}") +def get_file(project_id: str, file_path: str) -> WorkspaceFile: + from app.backends.ms_agent import workspace + + return workspace.get_file(project_id, file_path) + + +@router.put("/files/{file_path:path}") +def update_file(project_id: str, file_path: str, + body: WorkspaceFileUpdate) -> WorkspaceFile: + from app.backends.ms_agent import workspace + + return workspace.update_file(project_id, file_path, body) + + +@router.delete("/files/{file_path:path}", status_code=204) +def delete_file(project_id: str, file_path: str) -> None: + from app.backends.ms_agent import workspace + + return workspace.delete_file(project_id, file_path) diff --git a/webui/backend/app/backends/__init__.py b/webui/backend/app/backends/__init__.py new file mode 100644 index 000000000..313bea59f --- /dev/null +++ b/webui/backend/app/backends/__init__.py @@ -0,0 +1,17 @@ +"""Backend access. + +``get_backend()`` returns a process-wide singleton for the ms-agent SDK backend. +It used to pick between that and an in-memory ``mock`` backend (frontend-only +development without the SDK); the mock backend and its seed data have been +removed, so ms_agent is the only implementation. +""" +from __future__ import annotations + +from functools import lru_cache + + +@lru_cache(maxsize=1) +def get_backend(): + from app.backends.ms_agent.backend import MsAgentBackend + + return MsAgentBackend() diff --git a/webui/backend/app/backends/errors.py b/webui/backend/app/backends/errors.py new file mode 100644 index 000000000..4196ec977 --- /dev/null +++ b/webui/backend/app/backends/errors.py @@ -0,0 +1,23 @@ +"""Backend-domain errors that map straight to HTTP status codes. + +Subclassing HTTPException lets adapters raise semantic errors while FastAPI +renders them natively — routes stay thin and don't repeat status mapping. +""" +from __future__ import annotations + +from fastapi import HTTPException + + +class NotFound(HTTPException): + def __init__(self, detail: str = "not found") -> None: + super().__init__(404, detail) + + +class BadRequest(HTTPException): + def __init__(self, detail: str = "bad request") -> None: + super().__init__(400, detail) + + +class Conflict(HTTPException): + def __init__(self, detail: str = "conflict") -> None: + super().__init__(409, detail) diff --git a/webui/backend/app/backends/ms_agent/__init__.py b/webui/backend/app/backends/ms_agent/__init__.py new file mode 100644 index 000000000..df261c436 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/__init__.py @@ -0,0 +1 @@ +"""Real backend: adapters over the ms-agent SDK (consumed in-process).""" diff --git a/webui/backend/app/backends/ms_agent/agent_settings.py b/webui/backend/app/backends/ms_agent/agent_settings.py new file mode 100644 index 000000000..b546e139f --- /dev/null +++ b/webui/backend/app/backends/ms_agent/agent_settings.py @@ -0,0 +1,80 @@ +"""Agent-settings adapter — default model (ModelSettingsManager) + memory +defaults (PersonalizationSettings) + auto-attach masters (sidecar).""" +from __future__ import annotations + +from app.backends.ms_agent import model_link, sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import decode_model_id, encode_model_id +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.agent_settings import AgentSettings + + +def _ps(): + from ms_agent.personalization import PersonalizationSettings + + return PersonalizationSettings(global_dir=home()) + + +def get_settings() -> AgentSettings: + # default_model_id is a base64 Model.id (provider+name), matching /api/models + # so the frontend can highlight the selected model. + with settings_lock(): + provider, model = model_link.active_model() + default_model_id = encode_model_id(provider, model) if (provider and model) else None + cfg = _ps().load() + backend = cfg.memory_backend if cfg.memory_backend in ("file", "vector") else "file" + mem_cfg = sidecar.get("agent_settings", "memory_models", {}) or {} + embed_mode = mem_cfg.get("embed_mode") + return AgentSettings( + default_provider_id=provider, + default_model_id=default_model_id, + default_memory_enabled=bool(cfg.memory_enabled), + default_memory_backend=backend, + memory_llm_provider_id=mem_cfg.get("llm_provider_id"), + memory_llm_model=mem_cfg.get("llm_model"), + memory_embed_mode=embed_mode if embed_mode in ("provider", "local") else "provider", + memory_embed_provider_id=mem_cfg.get("embed_provider_id"), + memory_embed_model=mem_cfg.get("embed_model"), + memory_recall_top_k=mem_cfg.get("recall_top_k"), + global_mcp_auto_attach=sidecar.get("agent_settings", "global_mcp_auto_attach", True), + global_skill_auto_attach=sidecar.get("agent_settings", "global_skill_auto_attach", True), + ) + + +def update_settings(body: AgentSettings) -> AgentSettings: + from ms_agent.personalization import PersonalizationConfig + + with settings_lock(): + # default_model_id arrives as a base64 Model.id; decode and point the active + # model (llm block + default_model + catalog) at it so chat actually uses it. + if body.default_model_id: + try: + provider, model = decode_model_id(body.default_model_id) + model_link.set_active_model(provider, model) + except Exception: + pass + + ps = _ps() + cur = ps.load() + ps.save( + PersonalizationConfig( + global_instruction=cur.global_instruction, # preserve + memory_enabled=body.default_memory_enabled, + memory_backend=body.default_memory_backend, + ) + ) + sidecar.put("agent_settings", "global_mcp_auto_attach", body.global_mcp_auto_attach) + sidecar.put("agent_settings", "global_skill_auto_attach", body.global_skill_auto_attach) + sidecar.put( + "agent_settings", + "memory_models", + { + "llm_provider_id": body.memory_llm_provider_id, + "llm_model": body.memory_llm_model, + "embed_mode": body.memory_embed_mode, + "embed_provider_id": body.memory_embed_provider_id, + "embed_model": body.memory_embed_model, + "recall_top_k": body.memory_recall_top_k, + }, + ) + return get_settings() diff --git a/webui/backend/app/backends/ms_agent/backend.py b/webui/backend/app/backends/ms_agent/backend.py new file mode 100644 index 000000000..74fdf1c85 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/backend.py @@ -0,0 +1,22 @@ +"""MsAgentBackend facade — dispatches to the per-domain SDK adapters. + +Domain methods are added here as each endpoint is ported. Chat is stateful and +lives in the runtime/chat modules; the facade just forwards to it. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator + +from app.schemas.chat import ChatRequest + + +class MsAgentBackend: + def chat_stream(self, req: ChatRequest) -> AsyncIterator[dict]: + from app.backends.ms_agent import chat + + return chat.stream(req) + + def chat_attach(self, session_id: str) -> AsyncIterator[dict]: + from app.backends.ms_agent import chat + + return chat.attach(session_id) diff --git a/webui/backend/app/backends/ms_agent/bootstrap.py b/webui/backend/app/backends/ms_agent/bootstrap.py new file mode 100644 index 000000000..df9849def --- /dev/null +++ b/webui/backend/app/backends/ms_agent/bootstrap.py @@ -0,0 +1,148 @@ +"""ms_agent backend startup: home/env, default project, LLM settings seed. + +Runs once at app boot. Idempotent. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from app.core.settings import settings + + +def bootstrap() -> None: + from app.backends.ms_agent.common import apply_home_env, home, pm + + from app.backends.ms_agent import model_link + + apply_home_env() + _export_env() + pm() # ProjectManager.__init__ ensures ~/.ms_agent/projects + default project + _seed_llm_settings(home()) + _seed_tools_settings(home()) + # Normalize the model link so the chat dropdown lists the active model and + # default_model is a full "provider/model" (see model_link). + model_link.ensure_link() + + +def _export_env() -> None: + """Push backend credentials into the process env the SDK / MCP servers read.""" + for key, value in { + "OPENAI_API_KEY": settings.openai_api_key, + "OPENAI_BASE_URL": settings.openai_base_url, + "EXA_API_KEY": settings.exa_api_key, + }.items(): + if value and not os.environ.get(key): + os.environ[key] = value + + +def _seed_llm_settings(home_dir: str) -> None: + """Write settings.json `llm` from env when absent, so ConfigResolver yields a + working model. Matches §3.1: llm.{provider,model,api_key,base_url}.""" + path = Path(home_dir) / "settings.json" + data: dict = {} + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} + if "llm" in data: + return # never overwrite an existing config (e.g. a shared ~/.ms_agent) + + model = settings.ms_agent_llm_model + if not model: + return # nothing to seed; rely on framework default + env credentials + provider = settings.ms_agent_llm_provider or "openai" + llm: dict = {"provider": provider, "model": model} + # OPENAI_* are OpenAI's credentials, so only apply them when OpenAI is the + # provider being seeded. Copying them onto e.g. dashscope pinned the wrong + # base_url onto the llm block; leaving them out lets the SDK's + # CredentialResolver pick up that provider's own env vars + # (DASHSCOPE_API_KEY, DEEPSEEK_API_KEY, …). + if provider == "openai": + if settings.openai_api_key: + llm["api_key"] = settings.openai_api_key + if settings.openai_base_url: + llm["base_url"] = settings.openai_base_url + data["llm"] = llm + data.setdefault("default_model", f"{provider}/{model}") + + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +# Builtin repo tools seeded into settings.json so they are default-enabled and +# toggleable through the standard multi-level config resolve (framework yaml -> +# settings.json -> project yaml -> session). Presence of a `tools.` key +# enables it; add `enabled: false` to disable (honored by the SDK ToolManager). +_DEFAULT_TOOLS: dict = { + "file_system": { + "mcp": False, + "include": ["read_file", "grep", "glob", "edit_file", "write_file"], + }, + "todo_list": {"mcp": False}, + # Shell/terminal only. implementation must be the SDK's "python_env" (a + # local Jupyter kernel, no Docker); "local"/"sandbox" route to the Docker + # CodeExecutionTool (needs ms-enclave). `include: [shell_executor]` exposes + # ONLY the terminal tool (not notebook/python/file_operation). Restricted + # permission gates shell_executor (not whitelisted) so every command asks. + "code_executor": { + "mcp": False, + "implementation": "python_env", + "include": ["shell_executor"], + }, + # web_search needs exa-py + EXA_API_KEY; present but off by default. + "web_search": {"mcp": False, "engine": "exa", "enabled": False}, +} + +# task_control has no WebUI rendering component yet; strip it from any home that +# was seeded with the earlier default so it isn't loaded (idempotent migration). +_DROP_TOOLS = ("task_control",) + + +def _seed_tools_settings(home_dir: str) -> None: + """Write the default builtin-tool config into settings.json when absent, so + the tools are on out of the box and users can flip `enabled` per tool. Also + prunes retired defaults (``_DROP_TOOLS``) from an existing config.""" + path = Path(home_dir) / "settings.json" + data: dict = {} + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} + changed = False + if "tools" not in data: + data["tools"] = dict(_DEFAULT_TOOLS) + changed = True + else: + tools = data["tools"] + # Migration: drop retired default tools (only when present) so an + # already-seeded home stops loading a component the UI can't render. + for name in _DROP_TOOLS: + if name in tools: + del tools[name] + changed = True + # Add newly-introduced default tools that this home predates (e.g. + # code_executor), without overwriting a user's existing tool configs. + for name, cfg in _DEFAULT_TOOLS.items(): + if name not in tools: + tools[name] = cfg + changed = True + # Enforce "terminal = shell only": a code_executor without an explicit + # include/exclude (i.e. still the un-customized default) is narrowed to + # shell_executor. A user's own include/exclude is left untouched. + ce = tools.get("code_executor") + if isinstance(ce, dict) and "include" not in ce and "exclude" not in ce: + ce["include"] = ["shell_executor"] + changed = True + if not changed: + return + + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) diff --git a/webui/backend/app/backends/ms_agent/chat.py b/webui/backend/app/backends/ms_agent/chat.py new file mode 100644 index 000000000..a987d258d --- /dev/null +++ b/webui/backend/app/backends/ms_agent/chat.py @@ -0,0 +1,1513 @@ +"""POST /api/chat over Route A: enqueue one user turn, stream its events. + +The frontend sends the full message list each turn; the agent owns history via +SessionLog, so we only forward the latest user message. Events map to the +frontend's ChatChunk contract (frontend/app/lib/agentProvider.ts).""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from collections.abc import AsyncIterator + +from app.backends.ms_agent.common import ( + _is_cheap_title, + _is_default_name, + autoname_session, + find_session, + home, + resolve_project, + sm_for, +) +from app.backends.ms_agent import sidecar, titler +from app.backends.ms_agent.runtime import ( + DRIVER_DONE, + DRIVER_ERROR, + TURN_END, + registry, +) +from app.schemas.chat import ChatChunk, ChatRequest + +logger = logging.getLogger("app.ms_agent.chat") + + +def _content_parts(msg) -> tuple[str, list[str]]: + """Extract ``(plain_text, skill_ids)`` from a message whose ``content`` is + either a plain string or a configuration-style segment array + (``[{type: text|skill, ...}]``). Legacy ``msg.skills`` ids are merged in + for wire compatibility.""" + if msg is None: + return "", [] + content = msg.content or "" + if isinstance(content, str): + text, ids = content.strip(), [] + else: + text = " ".join( + s.text for s in content if s.type == "text" and s.text + ).strip() + # Candidates for the SDK's find_skill (slug / frontmatter name): the + # display name the composer sent resolves there; the WebUI asset id + # (``src::…``) is kept as a fallback candidate. + ids = [] + for s in content: + if s.type != "skill": + continue + for cand in (s.name, s.id): + if cand and cand not in ids: + ids.append(cand) + for sid in getattr(msg, "skills", None) or []: # deprecated field + if sid and sid not in ids: + ids.append(sid) + return text, ids + + +def _compose_prompt(msg) -> str: + """Build the prompt enqueued to the agent for one user turn. + + When the user attached files, append a plainly-delimited block listing each + file's workspace-relative path (``user_files/``). The agent's file + tools are scoped to the project workspace, so these paths resolve to the + real uploaded bytes on disk. The block is part of the enqueued prompt, so it + persists into the SessionLog user message — that record is the durable + session↔files mapping (and replays as text in history). + """ + if msg is None: + return "" + text, _ = _content_parts(msg) + files = getattr(msg, "files", None) or [] + if not files: + return text + lines = [ + "[Attached files] (paths are relative to the project workspace root; " + "use the file tools to read them):" + ] + for f in files: + lines.append(f"- {f.path}") + block = "\n".join(lines) + return f"{text}\n\n{block}" if text else block + + +def _touch_session(project, session_id: str) -> None: + """Bump the session's ``updated_at`` to now. + + The SDK only refreshes that timestamp inside ``SessionManager.update()``, + i.e. when session METADATA changes. Appending conversation rows goes through + SessionLog and never touches the meta file, so before this the field really + recorded "when the title was generated" (the one ``update()`` call webui + makes) rather than the last activity. Session lists are ordered by it, so a + long-running conversation stayed stuck at the bottom while brand-new ones + sat on top. Called at the START of every turn so the ordering reflects the + conversation the user is actually in, without waiting for it to finish. + """ + try: + sm_for(project).update(session_id) + except Exception: # ordering is cosmetic; never fail a turn over it + logger.debug("session touch failed", exc_info=True) + + +def _resolve_or_create(req: ChatRequest): + """Return (project, session). Reuse an existing session by id; otherwise + create one under the requested project (default project when unspecified).""" + if req.session_id: + found = find_session(req.session_id) + if found: + project, session, _sm = found + _touch_session(project, session.id) + return project, session + try: + project = resolve_project(req.project_id) + except KeyError: + project = resolve_project(None) # unknown id -> default project + session = sm_for(project).create() + return project, session + + +def _delta(chunk: ChatChunk) -> dict: + # Emit a plain SSE `data:` frame (default `message` event). The frontend + # routes purely on the payload's `type`, so no custom `event:` name is + # needed; the terminal frame is just a chunk with type=="done". + return {"data": chunk.model_dump_json()} + + +def _turn_frame(rt) -> dict: + """A `turn` frame carrying how long the RUNNING turn has been going. + + The turn origin lives on the runtime (set when the prompt is enqueued), so + it is the same clock for the original stream and for a later re-attach — a + client that joins (or reloads) mid-turn learns the real elapsed time + instead of restarting its counter from zero. + """ + origin = getattr(rt, "turn_started_at", None) + elapsed = int((time.monotonic() - origin) * 1000) if origin else 0 + return _delta(ChatChunk(type="turn", meta={"elapsed_ms": elapsed})) + + +# Re-send the turn age at most this often (seconds) while frames flow, so the +# client's ticking counter is re-based against the server clock (second-level +# drift correction) without flooding the stream. +_TURN_SYNC_INTERVAL = 5.0 + + +# Plan (todo) status -> frontend task status (agentProvider.ts TaskStatus). +_PLAN_STATUS = {"pending": "pending", "in_progress": "running", "completed": "done"} +# Candidate argument keys carrying a file path, for nicer file_read/file_write cards. +_PATH_KEYS = ("path", "file_path", "target_file", "filename", "file") + + +def _as_dict(args) -> dict: + """Tool arguments arrive as a dict or a raw JSON string; normalize to a dict.""" + if isinstance(args, str): + try: + args = json.loads(args) + except (ValueError, TypeError): + return {} + return args if isinstance(args, dict) else {} + + +def _stringify(value) -> str: + """Render a tool result for display: pass strings through, JSON-encode + dicts/lists, else str().""" + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + return str(value) + + +# Framework-generated assistant placeholder contents (SDK-side). They exist only +# to keep a content-less assistant message protocol-valid — NOT model output, so +# they must never surface in the UI. New logs are self-describing (the SDK flags +# synthetic filler with ``content_placeholder``, and no longer fills a tool-call +# turn at all); these literals are the OLD-log fallback. Mirrors +# ms_agent.agent.llm_agent (INTERRUPTED_PLACEHOLDER + the removed tool-call +# placeholder); kept webui-local to avoid importing SDK internals. +_INTERRUPTED_PLACEHOLDER = "[interrupted]" +_TOOLCALL_PLACEHOLDER = "Let me do a tool calling." + + +def is_placeholder_content(row: dict) -> bool: + """True when this row's ``content`` is framework filler, not model output. + + Structural first: ``content_placeholder`` (set by the SDK seal and by the + webui fallback seal) is authoritative and applies to ANY row shape. + + The literal comparison is only a fallback for logs written before that flag + existed, and is deliberately narrowed by corroborating structure so a + GENUINE reply that happens to equal a literal is never hidden: + + - ``[interrupted]`` counts only on a row flagged ``interrupted``; + - ``Let me do a tool calling.`` counts only on a row that carries + ``tool_calls`` (the only situation the SDK ever injected it). + + Non-string content (multimodal block lists) is never filler — and must not + be compared against a string set, which would raise TypeError on an + unhashable value and 500 the whole history endpoint. + """ + if row.get("content_placeholder"): + return True + content = row.get("content") + if not isinstance(content, str): + return False + if content == _INTERRUPTED_PLACEHOLDER: + return bool(row.get("interrupted")) + if content == _TOOLCALL_PLACEHOLDER: + return bool(row.get("tool_calls")) + return False + + +# Built-in tool servers (the SDK's own `server---tool` namespaces). Anything +# else with a `---` separator is an MCP server — the UI labels those "call +# MCP" instead of "call tool" (meta.source below). +_BUILTIN_SERVERS = { + "todo_list", "task_control", "file_system", "skills", "code_executor", + "web_search", "unified_memory", "agent_tools", +} + + +def _tool_source(name: str) -> str: + """``mcp`` for non-builtin `server---tool` names, else ``tool``.""" + base, sep, _ = name.partition("---") + return "mcp" if (sep and base not in _BUILTIN_SERVERS) else "tool" + + +def _tool_step_meta(name: str, args: dict) -> dict | None: + """Map one SDK tool call (`server---tool`) to a frontend `step` meta, or None + to drop it. Matching is on the full name (server + leaf), never the short + leaf alone, per the tool taxonomy. + + - todo_list / task_control -> None (plan machinery; shown as the task list). + - file_system read -> file_read (with a path). + - file_system write_file -> file_write; edit_file -> file_edit (distinct + cards: a full-content write vs an in-place edit render differently). + - file_system grep/glob -> search (a workspace query). + - skills skill_view -> skill_load (the only one that loads a skill: + its content, or one file inside it); skills_list -> skill_list (catalog + listing, or a search when `query` is set); skill_manage -> skill_manage + (create/edit/delete). + - code_executor shell/py/nb -> terminal (command / code body). + - web_search *_search -> search (a web query); fetch_page -> browser. + - unified_memory -> memory (add/replace/remove/read). + - anything else (MCP, agent_tools, media, ...) -> the generic tool_call card, + rendered by its unified tool name. + """ + base, _, leaf = name.partition("---") + if base in ("todo_list", "task_control"): + return None + if base == "file_system": + if leaf in ("grep", "glob"): + query = str(args.get("pattern") or args.get("glob") or args.get("path") or "") + return {"kind": "search", "name": name, "query": query, "scope": "files"} + path = next( + (args[k] for k in _PATH_KEYS if isinstance(args.get(k), str) and args[k]), + "", + ) + # A multi-file read/edit passes `paths: [...]`. Keep the joined string + # for display, but also surface the individual paths so the frontend's + # "still exists?" check tests each one (matching the joined string + # against the workspace set never hits — it false-flags "deleted"). + multi: list[str] = [] + if not path and isinstance(args.get("paths"), list): + multi = [str(p) for p in args["paths"] if p] + path = ", ".join(multi[:3]) + if path and leaf == "read_file": + m = {"kind": "file_read", "path": path, "name": name} + if multi: + m["paths"] = multi + return m + if path and leaf == "write_file": + m = {"kind": "file_write", "path": path, "name": name} + if multi: + m["paths"] = multi + return m + if path and leaf == "edit_file": + m = {"kind": "file_edit", "path": path, "name": name} + if multi: + m["paths"] = multi + return m + if base == "skills": + # The skills server exposes THREE unrelated actions; only skill_view + # actually pulls a skill's content into the turn, so only it may read as + # "load skill". + if leaf == "skills_list": + # One tool, two meanings: a bare call lists the catalog, a call with + # `query` searches it. + m = {"kind": "skill_list", "name": name} + query = str(args.get("query") or "") + if query: + m["query"] = query + return m + if leaf == "skill_manage": + return {"kind": "skill_manage", "name": name, + "action": str(args.get("action") or ""), + "skill": str(args.get("skill_id") or "")} + # skill_view: the skill itself, or one file inside it — the display name + # says which (the card renders this verbatim as its title chip). + skill = str(args.get("skill_id") or leaf or name) + file_path = str(args.get("file_path") or "") + return {"kind": "skill_load", + "name": f"{skill}/{file_path}" if file_path else skill} + if base == "code_executor": + if leaf == "shell_executor": + return {"kind": "terminal", "name": name, "code": str(args.get("command") or "")} + if leaf in ("python_executor", "notebook_executor"): + return {"kind": "terminal", "name": name, "code": str(args.get("code") or "")} + if base == "web_search": + if leaf == "fetch_page": + return {"kind": "browser", "name": name, "url": str(args.get("url") or "")} + return {"kind": "search", "name": name, "query": str(args.get("query") or ""), "scope": "web"} + if base == "unified_memory": + action = str(args.get("action") or ("read" if leaf == "memory_read" else "")) + return {"kind": "memory", "name": name, "action": action} + return {"kind": "tool_call", "name": name, "source": _tool_source(name)} + + +# Step kinds whose own card CARRIES the authorization ask instead of being +# preceded by a generic "call tool" card: a terminal ask must show the command +# as a code block with Reject/Run (the command IS what the user judges), which +# the frontend's TerminalStepCard already renders from `state`. A search ask +# likewise belongs on the search card — otherwise the raw `web_search---x` tool +# name is what the user sees while the search runs, instead of "searching {q}". +# Same for file operations: the path is the thing being judged, and the card can +# then say "reading {path}" while it runs. +_AUTH_INLINE_KINDS = ( + "terminal", "search", "file_read", "file_write", "file_edit", +) + + +def _inline_auth_meta(meta: dict, tool: str, args: dict) -> dict: + """Fold an authorization ask into its tool's own step card when that card + can host the decision (``_AUTH_INLINE_KINDS``), else return ``meta`` + unchanged (the generic authorization card). + + The ask keeps every authorization field (state / request_id / session_id / + tool_name / call_id ...) — only `kind` and the card's display payload come + from the tool mapping, so the frontend renders the tool's own card and + resolves the decision from the same card. + """ + step = _tool_step_meta(tool, args) + if step is None or step.get("kind") not in _AUTH_INLINE_KINDS: + return meta + return {**meta, **step} + + +class _TurnMapper: + """Assemble the frontend's ordered-parts view-model (agentProvider.ts) from + the SDK's flat semantic event stream. Stateful per turn: streams reasoning as + incremental `thought` frames (a final frame carries meta.duration to close the + block). Tool calls emit standalone `step` frames in stream order; a todo plan + (plan_updated) emits `task` frames rendered as a plain plan list — steps are + no longer nested under tasks.""" + + def __init__(self, session_id: str = "", resolved_permissions: list[dict] | None = None) -> None: + self._session_id = session_id + # Pre-resolved permissions (for attach replay only). Each entry has + # {tool_name, arguments, state}. Consumed in order (FIFO per tool_name). + self._resolved_perms: list[dict] = list(resolved_permissions or []) + self._reason_start: float | None = None + # call_id -> (name, args, group). A tool step is deferred to + # tool_call_completed (where ok/error is known), but the args + round + # group live on tool_call_started. + self._pending: dict[str, tuple[str, dict, int]] = {} + # Tool-round grouping mirrors the assistant message's tool_calls ARRAY + # directly: the SDK emits the whole array as consecutive + # ToolCallStarted events BEFORE executing any of them, so "pending was + # empty" marks the array's first element — no boundary inference + # (content/reasoning heuristics) needed. + self._group_seq = 0 + # True once a todo_write / todo_render_md completed this turn: the + # session plan files changed, so the loop's changed-files summary + # includes the reserved "plan.md" marker (todo steps themselves emit + # no card — this flag is the only trace). + self.plan_touched = False + # Raw plan locations the todo tool reported this turn (todo_write's + # `plan_path`, todo_render_md's target) — resolved at loop end to keep + # plan files (any configured name) out of the changed-files summary. + self.plan_reports: list[str] = [] + # The LAST markdown target a todo_render_md produced this turn (raw, + # workspace-relative). Only a FALLBACK for the loop's `plan_file` — the + # canonical plan.md todo_write maintains is preferred (see _plan_md_path). + self.latest_plan_md_report: str | None = None + + def map(self, payload: dict) -> list[ChatChunk]: + """Translate one AgentEvent dict into zero or more ChatChunks.""" + t = payload.get("type") + # Event timestamps (stamped by the sink buffer) keep durations truthful + # when a late viewer replays the buffer: elapsed time is between the + # ORIGINAL events, not the replay instants. + ts = payload.get("_ts") + now = ts if isinstance(ts, (int, float)) else time.monotonic() + if t == "content_delta": + return [ChatChunk(type="text", content=payload.get("text", ""))] + if t == "reasoning_started": + self._reason_start = now + return [] + if t == "reasoning_delta": + # Stream reasoning incrementally; the frontend appends to the open + # thought block. Start the clock lazily if we missed the start event. + if self._reason_start is None: + self._reason_start = now + text = payload.get("text", "") + return [ChatChunk(type="thought", content=text)] if text else [] + if t == "reasoning_ended": + return self._close_thought(now) + if t == "plan_updated": + return self._tasks(payload.get("entries", [])) + if t == "tool_call_started": + # Stash name+args+group by call_id — the completed event lacks + # arguments. The SDK flushes one round's WHOLE tool_calls array as + # consecutive started events before running any tool, so an empty + # pending map here means "first element of a new array" → new group. + if not self._pending: + self._group_seq += 1 + call_id = str(payload.get("call_id") or "") + name = payload.get("name", "") or "" + args = _as_dict(payload.get("arguments")) + self._pending[call_id] = (name, args, self._group_seq) + # Emit a live "running" card NOW so a slow tool (e.g. web_search) + # shows immediate feedback instead of a blank gap; the completed + # event's step (same call_id) replaces it in place on the frontend. + # The card's final shape (ok/error + result) is authored on + # completion in _tool_step. + return self._tool_running_step(name, args, self._group_seq, call_id) + if t == "tool_call_completed": + return self._tool_step(payload) + if t == "permission_request": + return self._permission_step(payload) + if t == "permission_resolved": + return self._permission_resolved_step(payload) + if t == "error": + return [ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })] + # user_message / turn_started / content_end / context_compacted -> none. + return [] + + def _tool_running_step(self, name: str, args: dict, group: int, + call_id: str) -> list[ChatChunk]: + """A live 'executing' card emitted on tool_call_started. The frontend + replaces it in place with the completed step (same call_id). Plan + machinery (todo_list / task_control) renders no card, so skip it here + too — its _tool_step_meta is None.""" + meta = _tool_step_meta(name, args) + if meta is None: + return [] + meta = {**meta, "tool": name, "arguments": args, "group": group, + "call_id": call_id, "status": "running"} + return [ChatChunk(type="step", meta=meta)] + + def _tool_step(self, payload: dict) -> list[ChatChunk]: + """Emit a finished tool call's step card, carrying its full invocation + (tool name + arguments + result) so the detail drawer can show it, plus + ok/error status. Plan-machinery tools (todo_list / task_control) skip.""" + call_id = str(payload.get("call_id") or "") + name, args, group = self._pending.pop( + call_id, + (payload.get("name", "") or "", {}, self._group_seq or 1), + ) + if name in ("todo_list---todo_write", "todo_list---todo_render_md") \ + and not payload.get("error"): + # The session plan files changed. Record the plan location the tool + # reported (todo_write's `plan_path`, todo_render_md's target) so + # loop end can keep plan files — under any configured name — out of + # the changed-files summary (and, as a fallback, locate `plan_file`). + self.plan_touched = True + self._note_plan_report(name, payload.get("result")) + meta = _tool_step_meta(name, args) + if meta is None: + return [] + # Full invocation, so the detail drawer can show arguments + result. + # call_id lets the frontend replace this call's live "running" card in + # place (instead of appending a duplicate). + meta = {**meta, "tool": name, "arguments": args, "group": group, + "call_id": call_id} + result = payload.get("result") + if result is not None: + meta["result"] = _stringify(result) + duration_s = payload.get("duration_s") + if isinstance(duration_s, (int, float)): + meta["duration_ms"] = int(duration_s * 1000) + if payload.get("error"): + meta = {**meta, "status": "error", "error": str(payload.get("error"))} + return [ChatChunk(type="step", meta=meta)] + + def _note_plan_report(self, name: str, result) -> None: + """Extract the plan file location a completed todo call reported and + stash it (raw, as the tool phrased it — resolved against the workspace + at loop end). ``todo_write`` returns JSON with ``plan_path``; + ``todo_render_md`` returns ``OK: rendered plan markdown to ``.""" + text = _stringify(result) if result is not None else "" + if not text: + return + if name == "todo_list---todo_render_md": + m = re.match(r"^OK: rendered plan markdown to (.+)$", text.strip()) + if m: + target = m.group(1).strip() + self.plan_reports.append(target) + self.latest_plan_md_report = target + return + # todo_write: JSON payload with a relative plan_path (the plan json). + try: + data = json.loads(text) + except (ValueError, TypeError): + return + pp = data.get("plan_path") if isinstance(data, dict) else None + if isinstance(pp, str) and pp: + self.plan_reports.append(pp) + + def _permission_step(self, payload: dict) -> list[ChatChunk]: + """Surface a restricted-mode ask (WebPermissionHandler emit) as an + authorization card. The turn is suspended on the handler's Future until + POST /api/chat/permission resolves it (or it times out to deny). + + A shell/code ask keeps its TERMINAL card instead (see _AUTH_INLINE_KINDS): + the command is what the user is judging, so it must be shown as the + terminal code block with Reject/Run, not as a generic "call tool" card + with JSON arguments. + + During attach replay, the session log's resolved state is used so the + card shows approved/rejected instead of stale pending buttons.""" + tool = str(payload.get("tool_name") or "") + call_id = str(payload.get("call_id") or "") + args = _as_dict(payload.get("tool_args")) + preview = json.dumps(args, ensure_ascii=False) + if len(preview) > 160: + preview = preview[:160] + "…" + # Check if this permission was already resolved (attach replay). Prefer + # an exact call_id match, else fall back to tool_name FIFO (old buffers). + state = "pending" + idx = next( + (i for i, r in enumerate(self._resolved_perms) + if call_id and str(r.get("call_id") or "") == call_id), + None, + ) + if idx is None: + idx = next( + (i for i, r in enumerate(self._resolved_perms) + if not str(r.get("call_id") or "") and r.get("tool_name") == tool), + None, + ) + if idx is not None: + state = self._resolved_perms.pop(idx).get("state", "pending") + # The ask belongs to its tool's round — share that group id so the + # auth card renders inside the same nested accordion. + pend = self._pending.get(call_id) + group = pend[2] if pend else (self._group_seq or 1) + meta = { + "kind": "authorization", + "state": state, + "request_id": str(payload.get("request_id") or ""), + "call_id": call_id, + "session_id": self._session_id, + "tool_name": tool, + "arguments": args, + "desc": f"{tool} {preview}".strip(), + "group": group, + "source": _tool_source(tool), + } + return [ChatChunk(type="step", meta=_inline_auth_meta(meta, tool, args))] + + def _permission_resolved_step(self, payload: dict) -> list[ChatChunk]: + """A refusal decided WITHOUT this client acting: the ask timed out, or + another viewer denied it. Emitted by the runtime's permission handler (the + SDK announces nothing when it times out), so the card flips to "rejected" + at the moment the decision is made instead of waiting for the gated call's + errored result. + + Deliberately carries no ``request_id``: the decision is final, so the card + must render its rejected state rather than live buttons. Shares the ask's + ``call_id`` and ``group`` so the frontend replaces that card in place. + """ + tool = str(payload.get("tool_name") or "") + call_id = str(payload.get("call_id") or "") + args = _as_dict(payload.get("tool_args")) + preview = json.dumps(args, ensure_ascii=False) + if len(preview) > 160: + preview = preview[:160] + "…" + pend = self._pending.get(call_id) + group = pend[2] if pend else (self._group_seq or 1) + meta = { + "kind": "authorization", + "state": str(payload.get("state") or "rejected"), + "call_id": call_id, + "session_id": self._session_id, + "tool_name": tool, + "arguments": args, + "desc": f"{tool} {preview}".strip(), + "group": group, + "source": _tool_source(tool), + } + return [ChatChunk(type="step", meta=_inline_auth_meta(meta, tool, args))] + + def flush(self) -> list[ChatChunk]: + """Close an open thought block and emit any pending (interrupted) tool calls + when a turn ends before their tool_call_completed arrived.""" + chunks = self._close_thought(time.monotonic()) + # Emit deferred tool calls that never completed (turn was interrupted). + for call_id, (name, args, group) in list(self._pending.items()): + meta = _tool_step_meta(name, args) + if meta is None: + continue + meta["status"] = "error" + meta["error"] = "[Interrupted: tool execution was cancelled]" + meta["group"] = group + # Same call_id as the live "running" card so the frontend flips that + # card to the interrupted state in place (no duplicate). + meta["call_id"] = call_id + chunks.append(ChatChunk(type="step", meta=meta)) + self._pending.clear() + return chunks + + def _close_thought(self, end: float) -> list[ChatChunk]: + """Emit a zero-width `thought` frame carrying the elapsed duration, which + finalizes the current thought block on the frontend. No-op if no reasoning + was in flight.""" + start, self._reason_start = self._reason_start, None + if start is None: + return [] + return [ChatChunk(type="thought", content="", + meta={"duration": max(0, round(end - start))})] + + def _tasks(self, entries: list) -> list[ChatChunk]: + # Re-send the whole plan on every update, one `task` chunk per row with + # index-stable ids. The frontend folds one burst into a frozen plan + # SNAPSHOT block appended at that point in the stream; a repeated id + # signals the next burst, so the stable ids delimit snapshots. + out: list[ChatChunk] = [] + for i, entry in enumerate(entries): + entry = entry if isinstance(entry, dict) else {"content": str(entry)} + out.append(ChatChunk(type="task", meta={ + "id": str(i), + "label": entry.get("content", ""), + "status": _PLAN_STATUS.get(entry.get("status", "pending"), "pending"), + })) + return out + + +def _seal_errored_turn(rt, session_id: str, prompt: str, + message: str = '') -> None: + """Make a failed turn survive a reload. + + A turn can raise before the SDK has written anything — credential + resolution and agent construction both happen ahead of the round loop. The + log then holds only its metadata header, so ``GET /messages`` returns an + empty conversation and the client's ``refreshMessages()`` (fired by the + ``done`` frame) replaces the live view — user bubble and error card included + — with nothing. The message looks like it was never sent. + + So: record the user turn ourselves when the log has no trace of it, then + close the round with an ``errored`` assistant row. ``errored`` rather than + ``interrupted`` because UIs badge the latter as a user-initiated Stop. + """ + log = getattr(getattr(rt, "agent", None), "session_log", None) + # A live agent persists its own turns: it consumed the prompt (writing the + # user row) and its exception handler seals the round. When it exists, a + # closed tail means "already sealed by the SDK" — re-appending the prompt + # here would duplicate the whole turn (user row, seal, and error record). + sdk_owns_turn = log is not None + if log is None: + # The most important case has no agent at all: credential resolution and + # agent construction run before the runtime exposes one, so `rt.agent` + # is unset for exactly the failures that most need recording. Reach the + # log the way replay does instead. + try: + found = find_session(session_id) + if not found: + return + _project, session, sm = found + log = sm.get_session_log(session) + except Exception: + logger.debug("seal errored turn: no session log", exc_info=True) + return + try: + msgs = log.get_all_messages() + # Only a pre-agent failure can leave the turn entirely unpersisted; for + # it, a closed tail (assistant, or an empty log) proves the prompt never + # reached the log, so write it — else refresh blanks the failed send. + if prompt and not sdk_owns_turn and (not msgs + or msgs[-1].get("role") + == "assistant"): + log.append({"role": "user", "content": prompt}) + msgs = log.get_all_messages() + if msgs and msgs[-1].get("role") in ("user", "tool"): + log.append({ + "role": "assistant", + "content": _INTERRUPTED_PLACEHOLDER, + "content_placeholder": True, + "errored": True, + }) + # The SDK records the error itself, but only from run_loop's handler — + # a failure during agent construction never gets there, so replay would + # show the message and an empty bubble with no reason. Record it here + # when this turn has none, keyed on "no error after the last user row" + # so a normal in-loop failure is not duplicated. + if message and hasattr(log, "get_errors"): + last_user = max( + (m.get("seq", -1) + for m in log.get_all_messages() if m.get("role") == "user"), + default=-1) + if not any( + e.get("seq", -1) > last_user for e in log.get_errors()): + log.record_error({ + "message": message, + "error_type": message.split(":", 1)[0], + "recoverable": False, + }) + except Exception: + logger.debug("seal errored turn skipped", exc_info=True) + + +def _seal_interrupted_turn(rt) -> None: + """Fallback seal: close the log if an aborted turn somehow left it open. + + The SDK now faithfully persists an interrupted round itself (run_loop's + cancellation handler seals partial content + synthesized tool results, all + flagged ``interrupted``), so normally the log already ends on an assistant + row when this runs. This guard only fires when that persistence could not + run (cancel before the round started, an older SDK, or a persist failure): + a dangling ``user``/``tool`` tail would make the rebuilt agent re-answer the + cancelled turn instead of the user's next message. The placeholder matches + the SDK's neutral marker — UIs render the ``interrupted`` flag, never the + literal content. + """ + agent = getattr(rt, "agent", None) + log = getattr(agent, "session_log", None) + if log is None: + return + try: + msgs = log.get_all_messages() + if msgs and msgs[-1].get("role") in ("user", "tool"): + log.append({ + "role": "assistant", + "content": _INTERRUPTED_PLACEHOLDER, + # Structured marker (mirrors the SDK seal): the content is + # synthetic filler, so replay renders the interrupted badge and + # hides the literal — no string-matching needed downstream. + "content_placeholder": True, + "interrupted": True, + }) + except Exception: + logger.debug("seal interrupted turn skipped", exc_info=True) + # A cancelled turn never reaches loop_end, so nothing would record its + # wall-clock duration — on replay the "processing Ns" header had no number + # to show and fell back to 0s (history carries no turn origin). Record the + # boundary here with the elapsed time up to the stop, so a reloaded page + # shows the same duration the live view froze at. + session_id = getattr(getattr(rt, "session", None), "id", "") + _persist_loop_end_from_log(rt, session_id) + + +async def _drain_abandoned_turn(rt, pos: int, session_id: str) -> None: + """The client left mid-turn WITHOUT an explicit stop (navigated to another + conversation). Keep this turn running in the background rather than cancel + it: the SDK driver keeps generating and persists each round to the + SessionLog, and the sink buffers the turn's events so a viewer can + re-attach later. This task just waits (from the leaver's cursor) for the + turn boundary, then releases the lock. + + This is deliberately not a cancel. Leaving a conversation — by navigation, + refresh, or even closing the browser — must let it finish (sessions run + concurrently on their own runtimes). The ONLY early stop is the explicit + POST /api/chat/interrupt. A *next* message to THIS session correctly waits + on the turn lock. + """ + try: + while True: + payload, pos = await rt.sink.next_event(pos) + if payload.get("type") in (TURN_END, DRIVER_DONE, DRIVER_ERROR): + break + # No live viewer will write the loop_end marker for this turn, so do it + # here: duration from the shared runtime turn origin, changed files + # derived from the session log (scoped to this turn). + _persist_loop_end_from_log(rt, session_id) + except Exception: # never let background cleanup crash + logger.debug("drain abandoned turn ended", exc_info=True) + finally: + if rt.turn_lock.locked(): + rt.turn_lock.release() + + +def _ws_root(rt) -> str | None: + """The runtime's workspace root (``project.path`` — where file-tool + relative paths resolve). None when unavailable.""" + try: + p = str(getattr(getattr(rt, "project", None), "path", "") or "") + return os.path.normpath(p) if p else None + except Exception: + return None + + +def _plan_md_path(rt, rendered_target: str | None = None) -> str | None: + """Absolute path of THE session plan markdown — the pointer the plan chip + labels itself with, kept consistent with the plan ``GET /sessions/{id}/plan`` + actually serves. + + Prefer the CANONICAL configured ``plan_md_filename``: with ``auto_render_md`` + on (the default), ``todo_write`` re-renders this file on EVERY plan change, + so it always mirrors the live ``plan.json`` the chip's popover reads (webui + points it at ``/plan.md``; an absolute value wins the join, so + an unusual configured location is honored too). A ``todo_render_md`` is an + explicit, optional export the model may aim anywhere under any name — it is + a copy, NOT necessarily the canonical plan — so its target is only a + fallback here, used when the configured path can't be resolved (e.g. + ``auto_render_md`` disabled with no config). None when nothing resolves. + + (The filtering set ``sessions.plan_paths_in_rows`` still collects ALL plan + locations — including every render target — so custom-named renders stay + out of the file ledgers regardless of this pointer choice.)""" + try: + cfg = getattr(rt.agent, "config", None) + tool_cfg = getattr(getattr(cfg, "tools", None), "todo_list", None) + raw = str(getattr(tool_cfg, "plan_md_filename", "") or "plan.md") + base = str(getattr(cfg, "output_dir", "") or "") + if base or os.path.isabs(raw): + return os.path.join(base, raw) + except Exception: + pass + # Fallback: an explicit render target, when the canonical path is unresolvable. + if rendered_target: + try: + ws = _ws_root(rt) + if ws or os.path.isabs(rendered_target): + return os.path.normpath(os.path.join(ws or "", rendered_target)) + except Exception: + pass + return None + + +def _persist_loop_end_from_log(rt, session_id: str) -> None: + """Record the loop_end marker for a turn that finished in the background + (no live observer). changed_files is derived from the current turn's rows + in the SessionLog; duration from the runtime turn origin.""" + try: + log = getattr(rt.agent, "session_log", None) + if log is None or not hasattr(log, "record_loop_end"): + return + from app.backends.ms_agent.sessions import ( + changed_files_in_rows, + latest_rendered_plan_md, + plan_paths_in_rows, + ) + + rows = log.get_all_messages() + # Scope to the last turn: assistant rows after the final user row. + last_user = max( + (i for i, r in enumerate(rows) if r.get("role") == "user"), + default=-1, + ) + # Idempotency: an explicit Stop drives BOTH the interrupt seal AND the + # aborted-SSE drain, and each lands here. Write at most one loop_end per + # turn — skip if this turn already has one (a loop_end whose seq is past + # the last user row). get_loop_ends() re-reads from disk, so it sees the + # other path's just-written marker; this fn is synchronous (no await) so + # the check-then-write can't interleave with the other task. + last_user_seq = rows[last_user].get("seq", -1) if last_user >= 0 else -1 + if hasattr(log, "get_loop_ends") and any( + le.get("seq", -1) > last_user_seq for le in log.get_loop_ends() + ): + return + turn_rows = rows[last_user + 1:] + ws = _ws_root(rt) + # Filter out-of-workspace writes + plan files (identified by the todo + # tool's own reports, so any configured plan filename is caught). + changed = changed_files_in_rows(turn_rows, ws) if ws else \ + changed_files_in_rows(turn_rows) + origin = getattr(rt, "turn_started_at", None) + duration_ms = int((time.monotonic() - origin) * 1000) if origin else 0 + payload = {"duration_ms": duration_ms, "changed_files": changed} + if "plan.md" in changed: + # Canonical plan.md (todo_write's); the latest render is a fallback. + rendered = latest_rendered_plan_md(turn_rows, ws) if ws else None + pf = _plan_md_path(rt, rendered) + if pf: + payload["plan_file"] = pf + log.record_loop_end(payload) + except Exception: + logger.debug("loop_end (drain) record skipped", exc_info=True) + + +# Upper bound on waiting for a session's turn lock. A healthy turn releases it +# in seconds; only a turn wedged on a dead endpoint holds it this long (the LLM +# client itself times out around 300s). On timeout we drop the wedged runtime +# and rebuild, so a stuck turn can't make later requests hang forever. +_LOCK_ACQUIRE_TIMEOUT = 330.0 + + +async def _acquire_live_runtime(project, session): + """Acquire a runtime turn lock, rebuilding if the driver died while waiting + or the lock stayed wedged past _LOCK_ACQUIRE_TIMEOUT.""" + while True: + rt = await registry.get(project, session) + try: + await asyncio.wait_for(rt.turn_lock.acquire(), timeout=_LOCK_ACQUIRE_TIMEOUT) + except asyncio.TimeoutError: + await registry.close(session.id) # drop the wedged runtime; rebuild next loop + continue + if not rt.run_task.done(): + return rt + rt.turn_lock.release() + + +def _catalog_for(rt): + """The runtime agent's SkillCatalog — the driver's own once prepare_skills + ran, else one built from the resolved config (timing-independent: a + freshly-built runtime hasn't reached prepare_skills yet).""" + agent = getattr(rt, "agent", None) + if agent is None: + return None + catalog = getattr(agent, "_skill_catalog", None) + if catalog is not None: + return catalog + from ms_agent.skill.catalog import SkillCatalog + + skills_config = getattr(getattr(agent, "config", None), "skills", None) + if not skills_config: + return None + catalog = SkillCatalog(config=skills_config) + catalog.load_from_config(skills_config) + return catalog + + +def _sync_runtime_skills(rt, project) -> None: + """Turn-boundary skill sync for a live session runtime. + + A long-lived agent's catalog is built once at prepare_skills; skills.json + edits and live-tree drops made after that (UI toggles, newly added or + deleted skills) would otherwise stay invisible until the runtime is + rebuilt. Called with the turn lock held (driver idle between turns): + re-merge the managed skill layer into the agent's config (replayable — + managed entries are origin-tagged and replaced wholesale) and resync the + catalog in place. The version bump makes the SDK's per-round + maybe_refresh_system_prompt() rebuild the system prompt only when the + skill surface actually changed. Best-effort: on failure the turn runs + with the previous catalog. + """ + agent = getattr(rt, "agent", None) + skill_runtime = getattr(agent, "_skill_runtime", None) if agent else None + if skill_runtime is None: + return # driver not through prepare_skills yet — it reads fresh config + try: + from ms_agent.tui.managed_config import merge_skills_into_config + + merge_skills_into_config(agent.config, home(), project.path) + skill_runtime.sync_with_config(agent.config.skills) + except Exception: + logger.debug("turn-boundary skill sync failed", exc_info=True) + + +def _strip_skill_token(text: str, skill) -> str: + """Remove the first whitespace-delimited ``/``/``/`` token for + the invoked skill, so the rest of the message becomes the arguments.""" + import re + + for candidate in (getattr(skill, "skill_id", ""), getattr(skill, "name", "")): + if not candidate: + continue + pattern = re.compile( + r"(?:(?<=\s)|^)/" + re.escape(candidate) + r"(?=\s|$)", + re.IGNORECASE, + ) + stripped, n = pattern.subn(" ", text, count=1) + if n: + # Mend the seam only (collapse doubled spaces/tabs); newlines and + # the rest of the message stay untouched. + return re.sub(r"[ \t]{2,}", " ", stripped).strip() + return text.strip() + + +def _expand_skill_request(rt, prompt: str, + skill_ids: list[str]) -> tuple[str, str, dict | None] | None: + """Two-tier slash-skill expansion (Route A runs no command router, so it + happens here before enqueuing). + + Tier 1 — structured: the composer sent the picked ``skills`` ids; expand + the first one with args = the prompt minus its ``/token`` (position-free, + parse-free). Tier 2 — free text: scan for the first whitespace-delimited + ``/token`` anywhere in the prompt that matches a catalog skill (covers + hand-typed and API input; unknown ``/x`` falls through as plain text). + + Returns ``(kind, content, marker)`` where kind ∈ {"submit", "message"} and + ``marker`` is the display-only skill-invocation record (submit only), or + None when nothing expanded. Never raises. + """ + from ms_agent.command.skill_bridge import ( + expand_skill, + expand_slash_text, + find_skill, + ) + from ms_agent.command.types import CommandResultType + + try: + catalog = _catalog_for(rt) + if catalog is None: + return None + result = None + invoked = None + for sid in skill_ids: # tier 1: first resolvable picked skill wins + skill = find_skill(catalog, sid) + if skill is None: + continue + invoked = skill + result = expand_skill( + catalog, sid, _strip_skill_token(prompt, skill)) + break + if result is None: + result = expand_slash_text(catalog, prompt) # tier 2 + if result is not None: + # Recover which skill matched (first known token) for the marker. + import re as _re + + for m in _re.finditer(r"(?:(?<=\s)|^)/([\w.-]+)(?=\s|$)", prompt): + skill = find_skill(catalog, m.group(1)) + if skill is not None: + invoked = skill + break + except Exception: # a broken skill must never break the chat + logger.debug("skill expand failed; sending text verbatim", exc_info=True) + return None + if result is None: + return None + if result.type == CommandResultType.SUBMIT_PROMPT: + # A composer skill pick with no typed text leaves prompt empty; fall + # back to the slash form so history replay shows a readable bubble. + shown = prompt or ( + f"/{invoked.skill_id}" if getattr(invoked, "skill_id", "") else prompt + ) + marker = { + "original_text": shown, + "skill_ids": [getattr(invoked, "skill_id", "")] if invoked else [], + } + return ("submit", result.content or prompt, marker) + if result.type == CommandResultType.MESSAGE: + return ("message", result.content or "", None) + return None # MUTATE_STATE / QUIT are not modeled over the SSE turn (v1) + + +async def _apply_title(project, session_id: str, text: str) -> dict | None: + """Generate an agent title + topic category for a session's first message and + persist them (session name + ``category`` sidecar). Returns the applied + ``{title, category}`` for the ``done`` frame, or None when generation failed + (the cheap first-line title from ``autoname_session`` then stands).""" + res = await titler.generate_title_and_category(text) + if not res: + return None + title, category = res + try: + sm_for(project).update(session_id, name=title) + except Exception: # naming is best-effort; keep the fallback title + logger.debug("title update failed", exc_info=True) + try: + sidecar.merge("sessions", session_id, {"category": category}) + except Exception: + logger.debug("category persist failed", exc_info=True) + return {"title": title, "category": category} + + +async def stream(req: ChatRequest) -> AsyncIterator[dict]: + project, session = _resolve_or_create(req) + session_id = session.id + + user_msg = req.message + prompt = _compose_prompt(user_msg) + # A bare skill pick (pill only, no text) is a valid submission — the skill + # expansion below submits the skill body as the turn. Only truly empty + # input returns. + typed_text, picked_skill_ids = _content_parts(user_msg) + if not prompt and not picked_skill_ids: + done = ChatChunk( + type="done", + meta={"session_id": session_id, "project_id": project.id}, + ) + yield {"data": done.model_dump_json()} + return + + # Name a new session: set a cheap first-line title immediately (so the list + # updates without waiting), then, for a brand-new session, kick off an + # agent-generated title + topic category concurrently with the turn. The + # result is folded into the terminal `done` frame so the frontend refreshes + # the conversation lists with the summarized title and category icon. + # A bare skill pick has no typed text — seed with the slash form so the + # cheap title / titler don't fall back to the expanded wrapper text. + first_text = ( + typed_text + or prompt + or (f"/{picked_skill_ids[0]}" if picked_skill_ids else "") + ) + # "Needs a real title" covers both the SDK default name AND a cheap + # first-message slice (the project-page path pre-seeds title=text[:60] at + # createSession, which otherwise permanently suppressed the LLM titler). + needs_title = _is_default_name(session.name) or _is_cheap_title( + session.name, first_text + ) + autoname_session(project, session, first_text) + title_task = ( + asyncio.create_task(_apply_title(project, session_id, first_text)) + if needs_title + else None + ) + + # The session now exists on disk (created by _resolve_or_create, given a + # cheap first-line title). Announce it immediately so the client refreshes + # its conversation lists right away — without waiting for the whole turn or + # the agent title. The `done` frame later carries the summarized title + + # category for a second refresh and the URL redirect. + yield _delta(ChatChunk( + type="session", + meta={"session_id": session_id, "project_id": project.id}, + )) + + # Acquire the turn lock FIRST, then sync skills and expand slash + # invocations against the freshly-synced catalog. With the lock held the + # driver is idle between turns, so the in-place catalog resync cannot race + # a running generation, and the expansion sees mid-session skill changes + # (the pre-sync ordering is why a newly added skill is now invocable in + # the same session). `started` guards the lock across the pre-enqueue + # yields: an intro-only reply or a client disconnect must release it. + rt = await _acquire_live_runtime(project, session) + marker: dict | None = None + started = False + try: + _sync_runtime_skills(rt, project) + + # Skill-update notice (tail-only sync): compare the freshly-synced + # catalog against what this session was last told (skill_surface.json) + # and, on drift, prefix this turn's prompt with a + # carrying the full current list. The sidecar is committed only after + # the turn is actually enqueued — an intro-only reply or a failed + # enqueue re-fires the notice next turn instead of losing it. + notice: str | None = None + commit_notice = None + catalog0 = _catalog_for(rt) + if catalog0 is not None: + try: + from app.backends.ms_agent.skill_notice import pending_notice + + notice, commit_notice = pending_notice(catalog0, project, session) + except Exception: + logger.debug("skill notice check failed", exc_info=True) + user_typed = prompt # pre-expansion text, for the display marker + + # Slash-skill invocation, two tiers (Route A doesn't route commands, + # so it happens here): the composer's structured `skills` ids first, + # else a scan for a whitespace-delimited /token anywhere in the text. + # Any invocation — with or without args — runs the enriched skill + # prompt as the turn (a bare /skill submits the skill body so the + # model reads it and acts); a display-only marker keeps the user's + # ORIGINAL text for history replay. The "message" branch below stays + # as a fallback for other CommandResult kinds. + skill_ids = _content_parts(user_msg)[1] + if skill_ids or "/" in prompt: + expanded = _expand_skill_request(rt, prompt, skill_ids) + if expanded is not None: + kind, content, marker = expanded + if kind == "message": + if content: + yield _delta(ChatChunk(type="text", content=content)) + done = ChatChunk( + type="done", + meta={"session_id": session_id, "project_id": project.id}, + ) + yield {"data": done.model_dump_json()} + return + prompt = content # "submit": run the enriched skill prompt as the turn + + if notice: + # Prepend AFTER expansion so token stripping never sees the + # notice. The model reads the notice; the UI shows the typed text + # via the display marker (same mechanism as slash expansion). + prompt = notice + "\n\n" + prompt + if marker is None: + marker = {"original_text": user_typed, "skill_ids": []} + + if marker is not None and isinstance( + getattr(user_msg, "content", None), list + ): + # Persist the configuration-style segments AS SENT (skill id + + # display name + text) so history replays exactly what the user + # saw in the composer (pills with readable names, not raw ids). + marker["segments"] = [s.model_dump() for s in user_msg.content] + elif marker is None and isinstance( + getattr(user_msg, "content", None), list + ) and any(s.type == "skill" for s in user_msg.content): + # Skill pills were sent but nothing expanded (e.g. the skill was + # deleted): still persist a display marker so the echo keeps the + # pills instead of silently dropping them. + marker = { + "original_text": user_typed, + "skill_ids": [], + "segments": [s.model_dump() for s in user_msg.content], + } + + completed = False + rt.sink.new_turn() # this turn owns the event buffer from here + await rt.enqueue(prompt, marker=marker) + # Turn wall-clock origin on the runtime, so whoever observes the turn's + # end (this live stream OR the background drain) records the same + # loop_end duration regardless of when the client left. + rt.turn_started_at = time.monotonic() + # Wall-clock twin of the origin, for comparisons against file mtimes + # (e.g. "was plan.json written during THIS turn" in sessions.read_plan). + rt.turn_started_wall = time.time() + rt.watchers += 1 # a live SSE consumer is streaming this turn + started = True + if notice and commit_notice is not None: + commit_notice() # the model has now been told — persist the surface + finally: + if not started and rt.turn_lock.locked(): + rt.turn_lock.release() + usage = None + error_sent = False + mapper = _TurnMapper(session_id) + pos = 0 + # This turn = one tool-call loop. Track wall-clock + files written/edited so + # the terminal `done` frame can carry a loop summary (frontend collapses the + # intermediate steps into a "done · Ns" header listing what changed). The + # history-replay counterpart is SessionMessage.changed_files. + loop_start = time.monotonic() + changed_live: list[str] = [] + changed_seen: set[str] = set() + + ws = _ws_root(rt) + + def _collect_changed(chunks: list) -> None: + # Keep RAW write/edit paths (as the tool phrased them); classification + # into workspace-relative deliverables happens at _changed_files() time, + # once all of this turn's plan reports are known. + for c in chunks: + meta = getattr(c, "meta", None) or {} + if c.type == "step" and meta.get("kind") in ("file_write", "file_edit"): + p = str(meta.get("path") or "") + if p and p not in changed_seen: + changed_seen.add(p) + changed_live.append(p) + + def _changed_files() -> list[str]: + # Workspace deliverables written/edited this loop, plus the reserved + # "plan.md" marker when the todo plan changed. A write is a deliverable + # only if it resolves INSIDE the workspace and isn't a plan file — the + # model may copy its plan into the session dir (via ``..``/absolute) or + # render it under any name; those are plan/session state, surfaced by + # the plan chip (content via GET /sessions/{id}/plan), not file cards. + from app.backends.ms_agent.sessions import ( + _resolve_tool_path, + _workspace_rel, + ) + + files: list[str] = [] + if ws: + plan_abs = { + _resolve_tool_path(ws, r) for r in mapper.plan_reports + } + for p in changed_live: + ap = _resolve_tool_path(ws, p) + if ap in plan_abs: + continue + rel = _workspace_rel(ws, ap) + if rel is not None: + files.append(rel) + else: + files = list(changed_live) + if mapper.plan_touched and "plan.md" not in files: + files.append("plan.md") + return files + + def _loop_duration_ms() -> int: + # Prefer the runtime turn origin (shared with the background drain), so + # live and replay report the same duration; fall back to this stream's + # observation start. + origin = getattr(rt, "turn_started_at", None) or loop_start + return int((time.monotonic() - origin) * 1000) + + def _loop_meta() -> dict: + # One summary shape for both the `done` frame and the persisted + # loop_end marker. When the turn touched the todo list, `plan_file` + # carries the plan markdown's ABSOLUTE path (session dir under webui + # config) so the frontend can key the plan chip without an + # exists-in-workspace check ("plan.md" in changed_files is the marker, + # plan_file the location; content via GET /sessions/{id}/plan). + meta = { + "changed_files": _changed_files(), + "duration_ms": _loop_duration_ms(), + } + if mapper.plan_touched: + # The canonical plan.md todo_write maintains (consistent with + # GET /plan); the latest todo_render_md target is only a fallback. + pf = _plan_md_path(rt, mapper.latest_plan_md_report) + if pf: + meta["plan_file"] = pf + return meta + + def _persist_loop_end() -> None: + # Durable loop boundary so history replay can reproduce the "done · Ns" + # summary (duration is not derivable from message rows). Best-effort; + # written once per turn by whoever observes its end. + try: + log = getattr(rt.agent, "session_log", None) + if log is not None and hasattr(log, "record_loop_end"): + log.record_loop_end(_loop_meta()) + except Exception: + logger.debug("loop_end record skipped", exc_info=True) + + async def _title_meta() -> dict: + """Await the concurrent titling task (if any) and return its meta for the + `done` frame. Best-effort: an error/timeout just omits title+category.""" + if title_task is None: + return {} + try: + res = await title_task + except Exception: + return {} + return {"title": res["title"], "category": res["category"]} if res else {} + + try: + # Turn age first: the client bases its "processing Ns" counter on the + # server clock from frame one (and re-bases on the periodic re-send). + yield _turn_frame(rt) + last_turn_sync = time.monotonic() + while True: + payload, pos = await rt.sink.next_event(pos) + t = payload.get("type") + + if t in (TURN_END, DRIVER_DONE): + flushed = mapper.flush() + _collect_changed(flushed) + for chunk in flushed: + yield _delta(chunk) + _persist_loop_end() # durable loop boundary (before the done frame) + done = ChatChunk( + type="done", + meta={ + "session_id": session_id, + "project_id": project.id, + "usage": usage, + **_loop_meta(), + **(await _title_meta()), + }, + ) + completed = True + yield {"data": done.model_dump_json()} + return + if t == DRIVER_ERROR: + completed = True + if not error_sent: + yield _delta(ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })) + # Close the round before the done frame. Without this the log + # tail stays on a `user`/`tool` row and the next request replays + # this same failing round instead of reading the new prompt + # (the SDK seals too, but only once the round loop was reached). + _seal_errored_turn( + rt, session_id, prompt, + message=payload.get("message", "")) + _persist_loop_end() + done = ChatChunk( + type="done", + meta={ + "session_id": session_id, + "project_id": project.id, + **_loop_meta(), + **(await _title_meta()), + }, + ) + yield {"data": done.model_dump_json()} + return + if t == "turn_completed": + usage = payload.get("usage") # per-round; kept for the done frame + continue + + if t == "error": + error_sent = True + mapped = mapper.map(payload) + _collect_changed(mapped) + for chunk in mapped: + yield _delta(chunk) + if time.monotonic() - last_turn_sync >= _TURN_SYNC_INTERVAL: + yield _turn_frame(rt) # re-base the client's counter + last_turn_sync = time.monotonic() + finally: + rt.watchers -= 1 # this live consumer is gone (finished or left) + if completed: + if rt.turn_lock.locked(): + rt.turn_lock.release() + else: + # Client left mid-turn without an explicit stop (navigated away, + # refreshed, or closed the browser): keep the turn running in the + # background and release the lock at the turn boundary, so the + # conversation finishes and persists — and a viewer can re-attach + # via POST /api/chat/attach. Only POST /api/chat/interrupt cancels. + asyncio.create_task(_drain_abandoned_turn(rt, pos, session_id)) + + +async def attach(session_id: str) -> AsyncIterator[dict]: + """Re-attach a viewer to a session's in-flight turn (SSE). + + Replays the current turn's buffered events from the start (full catch-up: + thoughts with their real elapsed times, steps, text so far) and then + follows the live tail until the turn boundary — the same ChatChunk protocol + as POST /api/chat, so the frontend renders it identically. When no turn is + in flight, emits just a `done` frame (the viewer falls back to history). + """ + rt = registry.peek(session_id) + if rt is None or not registry.is_running(session_id): + done = ChatChunk(type="done", meta={"session_id": session_id}) + yield {"data": done.model_dump_json()} + return + + rt.watchers += 1 # a live viewer is streaming this turn again + usage = None + # Pre-scan the event buffer so replayed authorization cards show their REAL + # state instead of stale pending buttons. Truth sources (not heuristics): + # - the live handler's `_pending` futures: a request still awaiting the + # user replays as pending (buttons work — the Future is alive); + # - everything else was decided; the decision's approve/reject state comes + # from the persisted permission records (written at resolve time). + # The old "any event after the ask means approved" inference broke the + # common case of an APPROVED slow tool: no event follows while it runs, so + # a refresh replayed dead pending buttons whose clicks could only fail + # (the Future was long resolved → the card flipped to "rejected"). + resolved_perms: list[dict] = [] + try: + buf = list(rt.sink._events) + perm_events = [ + ev for ev in buf if ev.get('type') == 'permission_request' + ] + if perm_events: + handler = getattr(rt, 'permission_handler', None) + pending_ids = { + rid + for rid, fut in (getattr(handler, '_pending', None) or {}).items() + if not fut.done() + } + records: list[dict] = [] + try: + found = find_session(session_id) + if found: + _proj, _sess, sm = found + log = sm.get_session_log(_sess) + if hasattr(log, 'get_permissions'): + records = list(log.get_permissions()) + except Exception: + records = [] + for ev in perm_events: + rid = str(ev.get('request_id') or '') + if rid and rid in pending_ids: + continue # genuinely awaiting the user's decision + call_id = str(ev.get('call_id') or '') + tool = str(ev.get('tool_name') or '') + rec = next( + (r for r in records + if call_id and str(r.get('call_id') or '') == call_id), + None, + ) or next( + (r for r in records if r.get('tool_name') == tool), + None, + ) + resolved_perms.append({ + 'tool_name': tool, + 'call_id': call_id, + # A decided ask with no record yet (persistence raced) can + # only have been approved — a denial ends the call at once. + 'state': str((rec or {}).get('state') or 'approved'), + }) + except Exception: + pass + mapper = _TurnMapper(session_id, resolved_permissions=resolved_perms) + pos = 0 + try: + # The rejoining client (a reload, or a second viewer) has no idea when + # this turn started — tell it, before replaying anything, so its + # "processing Ns" counter continues instead of restarting at zero. + yield _turn_frame(rt) + last_turn_sync = time.monotonic() + while True: + payload, pos = await rt.sink.next_event(pos) + t = payload.get("type") + if t in (TURN_END, DRIVER_DONE, DRIVER_ERROR): + for chunk in mapper.flush(): + yield _delta(chunk) + if t == DRIVER_ERROR: + yield _delta(ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })) + done = ChatChunk( + type="done", meta={"session_id": session_id, "usage": usage} + ) + yield {"data": done.model_dump_json()} + return + if t == "turn_completed": + usage = payload.get("usage") + continue + for chunk in mapper.map(payload): + yield _delta(chunk) + if time.monotonic() - last_turn_sync >= _TURN_SYNC_INTERVAL: + yield _turn_frame(rt) # re-base the client's counter + last_turn_sync = time.monotonic() + finally: + rt.watchers -= 1 diff --git a/webui/backend/app/backends/ms_agent/common.py b/webui/backend/app/backends/ms_agent/common.py new file mode 100644 index 000000000..d5607ff72 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/common.py @@ -0,0 +1,150 @@ +"""Shared SDK access helpers: home dir, project/session resolution. + +All ms_agent-backed adapters go through here so the global home and the +project/session lookups stay consistent (and honor MS_AGENT_HOME). +""" +from __future__ import annotations + +import os + +from app.core.settings import settings + + +def home() -> str: + """The SDK global home (~/.ms_agent unless MS_AGENT_HOME is set).""" + from ms_agent.project.paths import global_home + + return str(global_home()) + + +def apply_home_env() -> None: + """Bridge settings.ms_agent_home -> MS_AGENT_HOME so the SDK's global_home() + resolves to the WebUI home from any entry point. An explicitly-set + MS_AGENT_HOME (shell export / test isolation) always wins.""" + if settings.ms_agent_home and not os.environ.get("MS_AGENT_HOME"): + os.environ["MS_AGENT_HOME"] = os.path.expanduser(settings.ms_agent_home) + + +# Apply on import. This module is the single entry point for every ms_agent +# adapter, so merely importing it (server, CLI, script, test) pins MS_AGENT_HOME +# before any global_home() read — no more silent fallback to ~/.ms_agent when a +# script/CLI skips the app's boot path. +apply_home_env() + + +def pm(): + from ms_agent.project import ProjectManager + + return ProjectManager(base_dir=home()) + + +def sm_for(project): + from ms_agent.project import SessionManager + + return SessionManager(project) + + +def resolve_project(project_id: str | None): + """Return the Project for an id, falling back to the default project. + + Raises KeyError if a non-null id does not exist. + """ + manager = pm() + if not project_id: + return manager.get_default_project() + proj = manager.get(project_id) + if proj is None: + raise KeyError(f"project not found: {project_id}") + return proj + + +def _is_default_name(name: str) -> bool: + """SessionManager.create() names sessions 'Session <6hex>' by default.""" + return not name or name.startswith("Session ") + + +def _is_cheap_title(name: str, first_text: str) -> bool: + """True when ``name`` is just a leading slice of the first message — a + placeholder, not a real title. Both cheap-title writers produce prefixes: + the frontend seeds ``text.slice(0, 60)`` at createSession (the project-page + first-message path) and ``autoname_session`` uses line1[:40]. Without this + check those sessions never get an LLM title (``_is_default_name`` sees a + non-default name and skips the titler).""" + name = (name or "").strip() + if not name: + return True + head = (first_text or "").strip() + if not head: + return False + return head.startswith(name) or head.splitlines()[0].startswith(name) + + +def _title_from_text(text: str) -> str: + return text.strip().splitlines()[0][:40] if text and text.strip() else "" + + +def _first_user_line(project, session) -> str: + """Cheap: first line of the first user message in the session log.""" + import json + + from ms_agent.project.paths import global_projects_root + + path = (global_projects_root() / project.id / "sessions" / session.id + / f"{session.session_key}.jsonl") + if not path.exists(): + return "" + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("role") == "user" and msg.get("content"): + return _title_from_text(str(msg["content"])) + except OSError: + return "" + return "" + + +def autoname_session(project, session, text: str | None = None): + """Name a still-default session after its first user message (like ChatGPT / + the TUI). Uses `text` when given (chat's new message), else derives from the + session log. Returns the (possibly updated) session.""" + if not _is_default_name(session.name): + return session + title = _title_from_text(text) if text else _first_user_line(project, session) + if not title: + return session + try: + return sm_for(project).update(session.id, name=title) + except Exception: + return session + + +def find_session(session_id: str): + """Locate a session by id across all projects. + + Sessions live at ~/.ms_agent/projects//sessions//session.json. + Returns (Project, Session, SessionManager) or None. + """ + from ms_agent.project.paths import global_projects_root + + root = global_projects_root() + if not root.exists(): + return None + manager = pm() + for entry in root.iterdir(): + if not entry.is_dir(): + continue + meta = entry / "sessions" / session_id / "session.json" + if not meta.exists(): + continue + project = manager.get(entry.name) + if project is None: + continue + sm = sm_for(project) + session = sm.get(session_id) + if session is not None: + return project, session, sm + return None diff --git a/webui/backend/app/backends/ms_agent/config.py b/webui/backend/app/backends/ms_agent/config.py new file mode 100644 index 000000000..752a8cdae --- /dev/null +++ b/webui/backend/app/backends/ms_agent/config.py @@ -0,0 +1,894 @@ +"""Assemble the per-session run config and construct the LLMAgent (Route A). + +Mirrors ms_agent/tui/app.py: ConfigResolver.resolve() for the layered config +(framework defaults -> settings.json -> project patch -> session overrides), +then route-A shaping (interactive lifecycle, streaming, session-log dir), then +the managed MCP/skills bridge, then LLMAgent with the UI seams injected. +""" +from __future__ import annotations + +import logging +import os + +from app.backends.ms_agent.common import home + +logger = logging.getLogger("app.ms_agent.config") + +# mem0 2.x opens a SECOND, process-global qdrant store at +# ~/.mem0/migrations_qdrant for every Memory instance whose vector provider is +# qdrant (mem0/memory/main.py, `if MEM0_TELEMETRY:`). Embedded qdrant takes an +# exclusive OS file lock per path, so that global store caps the whole machine +# at one live vector project — the second one dies with "Storage folder ... is +# already accessed by another instance". Turning telemetry off skips the branch +# entirely (and stops mem0 phoning home to PostHog). +# +# mem0's telemetry module reads this at ITS import time, so it has to be set +# before the first `import mem0`. Module scope is early enough: every mem0 +# import in this repo is lazy (`_vector_memory_available` below, `memory.py`, +# and the SDK's mem0_adapter.start()), and memory.py imports this module first. +os.environ.setdefault("MEM0_TELEMETRY", "false") + + +def _apply_webui_defaults(config): + """Make a newly-created WebUI session useful without requiring a hand-written + agent.yaml. Project/global config can still override these keys.""" + from omegaconf import OmegaConf + + if OmegaConf.select(config, "skills", default=None) is None: + OmegaConf.update(config, "skills", {}, merge=True) + for key, value in { + "skills.prompt_injection": "all", + "skills.auto_discover": True, + "skills.enable_manage": False, + "skills.disabled": [], + # Skill changes are announced as in-conversation + # notices (chat._maybe_skill_notice); the SDK then keeps the system + # prompt byte-stable per session (head_refresh_enabled=False) so the + # provider prefix cache never breaks on a skill change. + "skills.update_notice": True, + }.items(): + if OmegaConf.select(config, key, default=None) is None: + OmegaConf.update(config, key, value, merge=True) + # Builtin repo tools live in settings.json's `tools` block and are merged by + # the SDK ConfigResolver (multi-level resolve); nothing to inject here. + return config + + +def _vector_memory_available() -> bool: + """The 'vector' project backend maps to the SDK's mem0 adapter (`mem0ai` + is a backend dependency; the guard keeps a broken install non-fatal).""" + try: + import mem0 # noqa: F401 + + return True + except Exception: + return False + + +class MemoryConfigError(RuntimeError): + """Vector memory cannot be built as configured. + + ``code`` is machine-readable for the UI: + - ``embed_unavailable``: no usable embeddings endpoint (provider serves + none / credentials missing) and no local fallback; + - ``local_missing``: local mode chosen but fastembed is not installed; + - ``embedder_mismatch``: the store was built with a different embedding + model — searching across a model swap silently degrades recall, so we + refuse and offer a rebuild instead. + """ + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +# Embedding models known to work per provider, verified against the exact call +# shape mem0 sends (``dimensions`` + ``encoding_format=float``). A provider +# absent here serves no /embeddings at all (verified: DeepSeek and Moonshot +# 403 it, MiniMax returns no data, a custom Aliyun MaaS endpoint 400s +# "Model not exist") — which is why "just use the chat provider" needs a +# fallback: about half of the chat providers cannot embed. +_KNOWN_EMBED_MODELS: dict[str, str] = { + "dashscope": "text-embedding-v4", + "openai": "text-embedding-3-small", + "modelscope": "Qwen/Qwen3-Embedding-4B", + "zhipu": "embedding-3", + "openrouter": "openai/text-embedding-3-small", +} + +# The bundled offline default: the lightest multilingual model fastembed +# ships (384 dims, ~220 MB one-time download, ONNX — no network at runtime). +_LOCAL_EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" + +_EMBEDDER_IDENTITY_FILE = "embedder.json" + + +def _read_settings() -> dict: + import json + + try: + with open(os.path.join(home(), "settings.json"), encoding="utf-8") as fh: + return json.load(fh) or {} + except (OSError, json.JSONDecodeError): + return {} + + +def _local_embed_available() -> bool: + """fastembed ships as the optional `local-embed` extra (it drags in + onnxruntime); absence is a normal state the UI must explain, not a bug.""" + try: + import fastembed # noqa: F401 + + return True + except Exception: + return False + + +def _project_memory_models(project) -> dict: + """The PROJECT's memory-model choices, materialized into its sidecar at + creation. The global settings block only seeds new projects (modal + prefill) — it is deliberately never read here, so changing a global + default cannot ripple through existing stores (which are pinned to their + embedder by identity anyway). Absent (legacy project) = follow defaults. + """ + from app.backends.ms_agent import sidecar + + meta = sidecar.get("projects", getattr(project, "id", None) or "", {}) or {} + return meta.get("memory_models") or {} + + +def _resolve_embedder(settings: dict, mem_cfg: dict) -> dict: + """Decide where embeddings come from. Never guesses silently: + + 1. explicit local mode -> the local model (error if not installed); + 2. explicit provider -> exactly that provider (error if it cannot embed — + the user pinned it, switching behind their back is the old bug); + 3. default -> the CONVERSATION provider if it is known to embed, else the + local model, else a clear error. ``fallback_reason`` records a taken + fallback so the UI can say why. + """ + providers = settings.get("providers") or {} + mode = mem_cfg.get("embed_mode") or "provider" + + def _local(reason: str | None = None) -> dict: + if not _local_embed_available(): + raise MemoryConfigError( + "local_missing", + "local embedding model is not installed — run " + "`uv sync --extra local-embed` in backend/ and restart", + ) + return { + "mode": "local", + "provider": None, + "model": mem_cfg.get("embed_model") or _LOCAL_EMBED_MODEL, + "fallback_reason": reason, + } + + if mode == "local": + return _local() + + explicit_pid = mem_cfg.get("embed_provider_id") + pid = explicit_pid or (settings.get("llm") or {}).get("provider") or "" + entry = providers.get(pid) or {} + model = mem_cfg.get("embed_model") or _KNOWN_EMBED_MODELS.get(pid) + if entry.get("api_key") and entry.get("base_url") and model: + return { + "mode": "provider", + "provider": pid, + "model": model, + "api_key": entry["api_key"], + "base_url": entry["base_url"], + "fallback_reason": None, + } + + if explicit_pid: + raise MemoryConfigError( + "embed_unavailable", + f"provider {pid!r} cannot serve embeddings as configured " + "(missing credentials or no known embedding model — set one " + "explicitly in settings → personalization)", + ) + # Following the conversation provider and it cannot embed: fall back to + # the local model rather than silently billing some other vendor. + reason = ( + f"provider {pid!r} serves no embeddings; using the local model" + if pid else "no conversation provider configured; using the local model" + ) + return _local(reason) + + +def _embedder_identity_path(project): + from ms_agent.project.paths import memory_dir + + return memory_dir(project.path) / _EMBEDDER_IDENTITY_FILE + + +def _load_embedder_identity(project) -> dict | None: + import json + + try: + with open(_embedder_identity_path(project), encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) and data.get("model") else None + except (OSError, ValueError): + return None + + +def _store_embedder_identity(project, identity: dict) -> None: + import json + + path = _embedder_identity_path(project) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(identity, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + except OSError as e: # bookkeeping must never break memory itself + logger.warning("embedder identity write failed for %s: %s", path, e) + + +def _probe_embed_dimension(desc: dict) -> tuple[int, bool]: + """``(dimension, provider_accepts_dimensions_param)`` for the resolved + embedder — measured, not assumed (mempalace's RFC 001 approach): the + width is baked into the qdrant collection at creation, and a hardcoded + table goes stale the moment a vendor changes a default. + + Providers are probed twice: once bare (native width), once passing + ``dimensions=`` — mem0 sends that argument whenever + ``embedding_dims`` is configured, and non-matryoshka backends reject it, + so we only configure it when the probe proved it is accepted. + """ + if desc["mode"] == "local": + from fastembed import TextEmbedding + + for m in TextEmbedding.list_supported_models(): + if m.get("model") == desc["model"] and m.get("dim"): + return int(m["dim"]), False + # Unknown to the table — load the model once and measure. + return int(TextEmbedding(model_name=desc["model"]).embedding_size), False + + from openai import OpenAI + + client = OpenAI( + api_key=desc["api_key"], base_url=desc["base_url"], + timeout=30, max_retries=0) + native = len( + client.embeddings.create( + model=desc["model"], input="dimension probe").data[0].embedding) + try: + echoed = len( + client.embeddings.create( + model=desc["model"], input="dimension probe", + dimensions=native).data[0].embedding) + return native, echoed == native + except Exception: + return native, False + + +def _openai_compat_base_url(provider: str) -> str | None: + """The vendor's canonical OpenAI-compatible endpoint, per the SDK registry. + + settings.json may hold a different-protocol url for the same vendor — + DeepSeek ships both ``/v1`` and ``/anthropic`` — while mem0's per-vendor + clients are OpenAI clients underneath. So take the registry's value rather + than doing string surgery on whatever the user configured. Returns None for + vendors that are not OpenAI-compatible at all (real Anthropic), where the + configured url is the right one. Same lookup as ``model_link``.""" + try: + from ms_agent.llm.spec import TRANSPORT_OPENAI_COMPAT, get_registry + except Exception: # pragma: no cover - import guard + return None + spec = get_registry().get(provider) + if spec is None or spec.transport != TRANSPORT_OPENAI_COMPAT: + return None + return spec.default_base_url or None + + +def _mem0_base_url_key(provider: str) -> str | None: + """mem0's base-url field name for a native provider (``deepseek_base_url``, + ``anthropic_base_url``, …), or None if it takes no such argument. + + Introspected rather than hardcoded: the set of native providers moves + between mem0 versions, and an unrecognised kwarg is a TypeError at config + construction, not a warning.""" + import inspect + + try: + from mem0.utils.factory import LlmFactory + + config_cls = LlmFactory.provider_to_class[provider][1] + params = inspect.signature(config_cls.__init__).parameters + except Exception: # pragma: no cover - unknown provider / mem0 layout change + return None + key = f"{provider}_base_url" + return key if key in params else None + + +def _mem0_llm(settings: dict, mem_cfg: dict | None = None) -> dict | None: + """The mem0 ``llm`` block used for per-round fact extraction. + + Defaults to the CONVERSATION model; an explicit choice in settings → + personalization (``memory_llm_provider_id``/``memory_llm_model``) pins + extraction to a model of the user's own, decoupled from what chat uses. + + The chosen model may be reached over the Anthropic protocol (DeepSeek's + ``/anthropic`` endpoint, for one). Declaring that as mem0's ``openai`` + provider — which this used to do unconditionally — makes mem0 POST + ``/anthropic/chat/completions`` and take a 404, silently: extraction + writes nothing and the memory panel stays empty forever. Resolve the + protocol instead of assuming it: + + 1. the provider already speaks OpenAI → mem0's ``openai`` provider; + 2. else mem0 has a native provider of the same name → use it, with the + vendor's OpenAI-compatible url from the SDK registry; + 3. else None — mem0 falls back to its own default, and we warn, because + picking a different vendor would mean guessing a model name it serves. + """ + mem_cfg = mem_cfg or {} + override_pid = mem_cfg.get("llm_provider_id") + override_model = mem_cfg.get("llm_model") + if override_pid and override_model: + entry = (settings.get("providers") or {}).get(override_pid) or {} + # Synthesize the same shape the follow-conversation path reads, so + # both flow through one protocol-resolution body below. + llm = { + "provider": override_pid, + "model": override_model, + "api_key": entry.get("api_key"), + "base_url": entry.get("base_url"), + } + else: + llm = settings.get("llm") or {} + model = llm.get("model") + if not model: + return None + pid = llm.get("provider") or "" + entry = (settings.get("providers") or {}).get(pid) or {} + api_key = llm.get("api_key") or entry.get("api_key") + if not api_key: + logger.warning("no api key for memory fact extraction (provider %r)", pid) + return None + + if entry.get("protocol") == "openai": + base_url = llm.get("base_url") or entry.get("base_url") + if base_url: + return { + "provider": "openai", + "config": { + "model": model, + "api_key": api_key, + "openai_base_url": base_url, + }, + } + + base_url_key = _mem0_base_url_key(pid) + if base_url_key is not None: + config = {"model": model, "api_key": api_key} + base_url = _openai_compat_base_url(pid) or entry.get("base_url") + if base_url: + config[base_url_key] = base_url + return {"provider": pid, "config": config} + + logger.warning( + "provider %r speaks %r and mem0 has no native adapter for it; leaving " + "the fact-extraction LLM to mem0's default — vector memory will likely " + "not be written", + pid, + entry.get("protocol") or "unknown", + ) + return None + + +def _resolve_embedder_identity(project, desc: dict) -> dict: + """The store's embedder identity: recorded once, then enforced. + + The identity file (``/.ms_agent/memory/embedder.json``) pins + which model produced the store's vectors. Checked at build time — before + any query — so a model swap fails fast with a rebuild path instead of + silently mixing vector spaces (mixed spaces don't error, they just make + recall garbage). A store predating the identity file adopts the current + embedder with a warning: its vectors cannot be attributed after the fact. + """ + import time as _time + + from ms_agent.project.paths import memory_dir + + current = {"provider": desc.get("provider") or "local", "model": desc["model"]} + stored = _load_embedder_identity(project) + if stored is not None: + if (stored.get("provider"), stored.get("model")) != ( + current["provider"], current["model"]): + raise MemoryConfigError( + "embedder_mismatch", + f"this project's memory was built with " + f"{stored.get('provider')}/{stored.get('model')} but the " + f"current embedder is {current['provider']}/{current['model']}" + " — searching across a model swap silently degrades recall. " + "Rebuild the memory store to switch.", + ) + return stored + + dims, pass_dims = _probe_embed_dimension(desc) + identity = { + **current, + "dimension": dims, + "pass_dimensions": pass_dims, + "created_at": _time.strftime("%Y-%m-%dT%H:%M:%S%z"), + } + if (memory_dir(project.path) / "qdrant").exists(): + identity["adopted_existing_store"] = True + logger.warning( + "project %s has a vector store predating embedder identity " + "tracking; adopting %s/%s for it", + project.id, current["provider"], current["model"]) + _store_embedder_identity(project, identity) + return identity + + +def _mem0_options(project) -> dict: + """mem0 backend options for a 'vector' project. + + - embedder: resolved by ``_resolve_embedder`` (explicit choice → + conversation provider → local model), identity-checked against the + store it is about to write into; + - llm (fact extraction): see ``_mem0_llm``; + - vector_store: local on-disk qdrant under the project memory dir + (embedded mode — no server; see the MEM0_TELEMETRY note at module top). + + Raises :class:`MemoryConfigError` when vector memory cannot be built as + configured — callers decide whether that surfaces (API) or degrades + (agent build).""" + from ms_agent.project.paths import memory_dir + + settings = _read_settings() + mem_cfg = _project_memory_models(project) + desc = _resolve_embedder(settings, mem_cfg) + identity = _resolve_embedder_identity(project, desc) + dims = int(identity["dimension"]) + + if desc["mode"] == "local": + embedder = { + "provider": "fastembed", + "config": {"model": desc["model"], "embedding_dims": dims}, + } + else: + config = { + "api_key": desc["api_key"], + "openai_base_url": desc["base_url"], + "model": desc["model"], + } + # mem0 forwards `dimensions` to the API whenever embedding_dims is + # set; only set it where the probe proved the backend accepts it. + if identity.get("pass_dimensions"): + config["embedding_dims"] = dims + embedder = {"provider": "openai", "config": config} + + options: dict = { + "embedder": embedder, + "vector_store": { + "provider": "qdrant", + "config": { + "path": str(memory_dir(project.path) / "qdrant"), + "on_disk": True, + "collection_name": "webui_memory", + "embedding_model_dims": dims, + }, + }, + } + llm = _mem0_llm(settings, mem_cfg) + if llm is not None: + options["llm"] = llm + return options + + +def _apply_webui_memory(config, project): + """Wire the project's memory toggle to the SDK's unified memory. + + Enabled -> `memory.unified_memory`, namespaced per project and rooted + under `/.ms_agent/memory/` (via output_dir): + - memory_backend "file" -> FileBasedBackend (MEMORY.md, default); + writes happen through the model's `memory` tool. + - memory_backend "vector" -> Mem0Backend (mem0 + local qdrant); writes + happen through mem0's per-round fact extraction, so the node also + activates the agent's `add_after_step` ingestion hook. + A vector project whose memory cannot be built (no embedder, identity + mismatch, mem0 missing) runs WITHOUT memory for the session — never as a + silent file fallback, which would write a MEMORY.md the vector UI never + shows. The reason reaches the user through GET /memory/status. + Disabled -> drop any lower-layer memory block so the WebUI toggle is + authoritative (no memory tools, no injection).""" + from omegaconf import OmegaConf + + if getattr(project, "memory_enabled", False): + pid = project.id or "default" + backend = getattr(project, "memory_backend", None) or "file" + node: dict = { + "storage": {"backend": "file"}, + "namespace": {"user_id": pid}, + # Legacy-shaped fields some agent paths read directly: + # SharedMemoryManager keying + add_memory()'s per-step ingestion + # (get_memory_meta_safe requires an explicit add_after_step block). + "user_id": pid, + "add_after_step": {"user_id": pid}, + } + if backend == "vector": + recall = (_project_memory_models(project) or {}).get("recall_top_k") + if isinstance(recall, int) and recall > 0: + node["recall_top_k"] = recall + node_ok = False + if _vector_memory_available(): + try: + options = _mem0_options(project) + node["storage"]["backend"] = "mem0" + node["mem0"] = options + node_ok = True + except MemoryConfigError as e: + logger.warning( + "vector memory disabled for project %s this session " + "(%s): %s", pid, e.code, e) + except Exception: + logger.warning( + "vector memory disabled for project %s this session", + pid, exc_info=True) + else: + logger.warning( + "vector memory disabled for project %s: mem0ai missing", pid) + if not node_ok: + if OmegaConf.select(config, "memory", default=None) is not None: + del config["memory"] + return config + elif backend != "file": + logger.warning("unknown memory_backend %r; using file", backend) + OmegaConf.update(config, "memory.unified_memory", node, merge=True) + # The WebUI drives unified_memory only. Legacy memory types leaking in + # from project/global config (notably `default_memory`, whose mem0 v1 + # calls break against the mem0 2.x we ship) are dropped, not merged. + mem_node = OmegaConf.select(config, "memory", default=None) + for key in [k for k in (mem_node or {}) if k != "unified_memory"]: + logger.warning("dropping legacy memory type %r (webui uses unified_memory)", key) + del mem_node[key] + elif OmegaConf.select(config, "memory", default=None) is not None: + del config["memory"] + return config + + +# Read-only / plan-machinery tools that restricted mode lets through without a +# confirmation card. Writes (write_file/edit_file), shell (code_executor) and +# MCP tools are NOT whitelisted, so each surfaces an authorization card. +_PERMISSION_WHITELIST = [ + "file_system---read_file", + "file_system---grep", + "file_system---glob", + "todo_list---*", + "unified_memory---*", + "skills---*", +] + + +def _apply_webui_permission(config, project_mode: str | None = None): + """Default this phase to the SDK's restricted (ask) mode. + + Non-whitelisted tools suspend on the session's WebPermissionHandler, which + surfaces an authorization card over SSE and times out to deny. Explicit + `permission.*` from settings.json / project config still wins — we only + fill blanks. ``project_mode`` is the UI's per-project override (the + composer's restricted/full-access selector, stored in the project sidecar): + an explicit user choice, so it beats the fill-blanks default.""" + from omegaconf import OmegaConf + + if project_mode in ("restricted", "auto"): + OmegaConf.update(config, "permission.mode", project_mode, merge=True) + elif OmegaConf.select(config, "permission.mode", default=None) is None: + OmegaConf.update(config, "permission.mode", "restricted", merge=True) + if OmegaConf.select(config, "permission.whitelist", default=None) is None: + OmegaConf.update( + config, "permission.whitelist", list(_PERMISSION_WHITELIST), merge=True + ) + return config + + +def thinking_default(protocol: str, provider: str, model: str = "") -> bool: + """Whether ``extra_body.enable_thinking`` is ON by DEFAULT for this + provider/model/protocol, before any user override. Single source of truth: + used by build shaping (``_apply_model_compatibility``) and surfaced to the + model-settings UI (``mapping.builtin_provider_to_schema``) so the effective + default is visible in the generation-params JSON. + + Thinking defaults on for the Anthropic protocol (where the flag maps to the + Messages API ``thinking`` param), for Qwen models, and for DashScope / + ModelScope; other OpenAI-compatible providers default it off (they stream + ``reasoning_content`` natively or reject the flag).""" + protocol = (protocol or "").lower() + provider = (provider or "").lower() + model = (model or "").lower() + return ( + protocol == "anthropic" + or "qwen" in model + or provider in {"dashscope", "modelscope"} + ) + + +def _apply_model_compatibility(config): + """Normalize provider/model quirks that break the WebUI's shared defaults.""" + import json + + from omegaconf import OmegaConf + + provider = str(OmegaConf.select(config, "llm.service", default="") or "").lower() + model = str(OmegaConf.select(config, "llm.model", default="") or "").lower() + temperature_enabled = False + webui_params = _webui_generation_params(provider, model) + try: + with open(os.path.join(home(), "settings.json"), encoding="utf-8") as fh: + temperature_enabled = bool( + ((json.load(fh).get("llm") or {}).get("temperature_enabled")) + ) + except (OSError, json.JSONDecodeError): + temperature_enabled = False + + # The SDK's base agent.yaml sets temperature=0.3 as a generic default. Many + # OpenAI-compatible models (including deepseek-v4-pro and kimi-k2.5 here) + # reject that value. In the WebUI, temperature should only be sent when the + # user explicitly enables/configures it. + temperature_explicit = temperature_enabled or "temperature" in webui_params + if not temperature_explicit: + generation_config = OmegaConf.select(config, "generation_config", default=None) + if generation_config is not None and "temperature" in generation_config: + del generation_config["temperature"] + + # Some current OpenAI-compatible reasoning/code models reject arbitrary + # temperature values and require temperature=1 when the field is present. + if temperature_explicit and ( + model.startswith("deepseek-v4") + or (provider == "deepseek" and "deepseek-v4" in model) + or model.startswith("kimi-k2") + or (provider == "kimi" and "kimi-k2" in model) + ): + OmegaConf.update(config, "generation_config.temperature", 1.0, merge=True) + + # enable_thinking is a Qwen/DashScope-style extra_body flag. DeepSeek and + # other OpenAI-compatible providers either stream reasoning_content directly + # or do not support the flag; default it off for them so we don't send an + # unsupported extra — UNLESS the user explicitly configured thinking params + # for this provider/model (per-provider thinking control via the WebUI model + # settings: provider default_generation_params / model advanced_params, which + # flow into generation_config via _apply_webui_generation_params). + # On the Anthropic protocol, enable_thinking IS the switch that turns on the + # provider's thinking mode (mapped to the Messages API `thinking` param), so + # keep it — the transport now replays thinking blocks through tool calls. + protocol = str( + OmegaConf.select(config, "llm.protocol", default="") or "").lower() + extra_body = webui_params.get("extra_body") + thinking_user_set = isinstance(extra_body, dict) and ( + "enable_thinking" in extra_body or "thinking_budget" in extra_body + ) + if not thinking_user_set and not thinking_default(protocol, provider, model): + OmegaConf.update( + config, + "generation_config.extra_body.enable_thinking", + False, + merge=True, + ) + return config + + +def _deep_merge(base: dict, override: dict) -> dict: + """Recursively merge ``override`` into ``base`` (nested dicts merged, not + replaced), returning a new dict. Non-dict values (and dict-vs-scalar + mismatches) are overwritten by ``override``.""" + out = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(out.get(key), dict): + out[key] = _deep_merge(out[key], value) + else: + out[key] = value + return out + + +def _webui_generation_params(provider: str, model: str) -> dict: + """Provider defaults + model overrides stored in WebUI sidecar metadata. + + Merged deeply so a model-level nested dict (e.g. ``extra_body``) refines the + provider-level one rather than replacing it — otherwise a model that sets a + single ``extra_body`` key would drop the provider's other ``extra_body`` + entries (e.g. ``enable_thinking``).""" + if not provider or not model: + return {} + from app.backends.ms_agent import sidecar + from app.backends.ms_agent.mapping import encode_model_id + + provider_params = ( + sidecar.get("providers", provider) or {} + ).get("default_generation_params") or {} + model_params = ( + sidecar.get("models", encode_model_id(provider, model)) or {} + ).get("advanced_params") or {} + params: dict = {} + if isinstance(provider_params, dict): + params = _deep_merge(params, provider_params) + if isinstance(model_params, dict): + params = _deep_merge(params, model_params) + return params + + +def _apply_webui_generation_params(config): + """Apply generation params configured from the WebUI model settings page.""" + from omegaconf import OmegaConf + + provider = str(OmegaConf.select(config, "llm.service", default="") or "") + model = str(OmegaConf.select(config, "llm.model", default="") or "") + for key, value in _webui_generation_params(provider, model).items(): + OmegaConf.update(config, f"generation_config.{key}", value, merge=True) + return config + + +def session_dir(project, session) -> str: + from ms_agent.project.paths import global_projects_root + + return str(global_projects_root() / project.id / "sessions" / session.id) + + +def session_has_history(project, session) -> bool: + from ms_agent.project import SessionManager + + try: + log = SessionManager(project).get_session_log(session) + return bool(log.get_all_messages()) + except Exception: + return False + + +# UI/meta fields on a managed MCP entry that must not reach the runtime server +# config (mirrors ms_agent.tui.managed_config._MCP_META). +_MCP_META = frozenset({ + "source", "meta", "_scope", "mcp", "implementation", "trust_remote_code", "_removed", +}) + + +def _mcp_reachable(server: dict, timeout: float = 2.0) -> bool: + """Cheap pre-flight so an unreachable MCP can't break the chat turn. + + Remote servers: TCP-connect to host:port. stdio servers: the command must + resolve on PATH (or be an existing file). Reachable-but-broken servers still + pass (rare); the common 'wrong/dead URL or missing command' case is dropped. + """ + import shutil + import socket + from urllib.parse import urlparse + + url = server.get("url") + if url: + try: + u = urlparse(url) + if not u.hostname: + return False + port = u.port or (443 if u.scheme in ("https", "wss") else 80) + with socket.create_connection((u.hostname, port), timeout=timeout): + return True + except Exception: + return False + command = server.get("command") + if command: + return bool(shutil.which(command)) or os.path.isfile(command) + return True # unknown shape — don't drop + + +def _healthy_mcp_config(mcp_config: dict | None) -> dict: + servers = (mcp_config or {}).get("mcpServers") or {} + healthy = {name: s for name, s in servers.items() if _mcp_reachable(s)} + dropped = set(servers) - set(healthy) + if dropped: + logger.warning("skipping unreachable MCP server(s): %s", ", ".join(sorted(dropped))) + return {"mcpServers": healthy} if healthy else {} + + +def build_agent(project, session, *, event_sink, input_source, mcp_config=None, + permission_handler=None): + from ms_agent.agent.llm_agent import LLMAgent + from ms_agent.config import ConfigResolver + from ms_agent.permission.handler import AutoPermissionHandler + from ms_agent.tui.managed_config import ( + merge_skills_into_config, + resolve_mcp_config, + ) + from omegaconf import OmegaConf + + h = home() + resolver = ConfigResolver(global_dir=h, project_root=project.path) + sdir = session_dir(project, session) + session_overrides = { + # ① align the runtime SessionLog with the SessionManager session dir + "session_log": { + "dir": sdir, + "session_key": session.session_key, + }, + # ② project-level personalization instruction + "personalization": {"project_instruction": project.instruction or ""}, + # ③ per-session todo plan: the todo_list tool joins these onto + # output_dir, and an absolute path wins the join — so each session's + # plan lives beside its session log instead of a project-shared + # /plan.json (which made concurrent sessions clobber each + # other's plans). read_plan() resolves the same path. + "tools": { + "todo_list": { + "plan_filename": os.path.join(sdir, "plan.json"), + "plan_md_filename": os.path.join(sdir, "plan.md"), + }, + }, + } + cfg = resolver.resolve( + agent_config=None, + project_path=project.path, + session_overrides=session_overrides, + ) + cfg = _apply_webui_defaults(cfg) + cfg = _apply_webui_memory(cfg, project) + # Restricted-by-default permission; the project sidecar's explicit + # restricted/full-access choice (composer selector) overrides. + from app.backends.ms_agent import sidecar + + meta = sidecar.get("projects", project.id, {}) or {} + cfg = _apply_webui_permission(cfg, meta.get("permission_mode")) + + # Route-A shaping (see tui/app.py::_prepare_config). + shaping = { + "interactive": True, # non-TTY backend: enable interactive lifecycle + # Route chat through the data-driven provider layer (ms_agent/llm/ + # router.py) rather than the legacy hard-coded LLM classes. The new + # layer supports mid-stream interrupt() — abandoning a turn closes the + # upstream streaming response so the server stops generating instead of + # running to completion into a dropped connection. The legacy path is + # being deprecated. + "llm.use_provider_router": True, + "generation_config.stream": True, + "generation_config.stream_output": True, + "generation_config.show_reasoning": True, + "generation_config.extra_body.enable_thinking": True, + "session_log.enabled": True, + "output_dir": project.path, + "max_chat_round": 1000, + } + for key, value in shaping.items(): + OmegaConf.update(cfg, key, value, merge=True) + # Propagate the active provider's wire protocol (openai | anthropic) so the + # provider layer picks the matching transport. A provider may point at + # another vendor's compatible endpoint (e.g. DeepSeek's /anthropic gateway), + # where the endpoint's protocol differs from the service's default transport. + _service = str(OmegaConf.select(cfg, "llm.service", default="") or "") + _protocol = ( + (_read_settings().get("providers") or {}).get(_service) or {} + ).get("protocol") + if _protocol: + OmegaConf.update(cfg, "llm.protocol", _protocol) + cfg = _apply_webui_generation_params(cfg) + cfg = _apply_model_compatibility(cfg) + # Drop any listed InputCallback so restarts never double-register it. + cbs = [c for c in list(getattr(cfg, "callbacks", []) or []) if c != "input_callback"] + OmegaConf.update(cfg, "callbacks", cbs, merge=False) + + # Bridge managed skill sources into the runtime (reused SDK/TUI helper). + cfg = merge_skills_into_config(cfg, h, project.path) + + # MCP: use the SDK's standard mcp_config path (enabled servers only) — far + # more robust to invalid servers than injecting an MCPRuntime (whose remote + # client teardown throws cross-task anyio errors). The registry pre-probes + # servers (connect+initialize) and passes only healthy ones as `mcp_config`; + # fall back to a cheap TCP check for direct callers (tests). Live + # enable/disable is sacrificed — a toggle applies on the next session build. + if mcp_config is None: + mcp_config = _healthy_mcp_config(resolve_mcp_config(h, project.path, None)) + + resume = session_has_history(project, session) + agent = LLMAgent( + cfg, + event_sink=event_sink, + input_source=input_source, + mcp_config=mcp_config or {}, + load_cache=resume, + ) + # The runtime passes a WebPermissionHandler (ask -> SSE authorization card + # -> POST /api/chat/permission resolve, deny on timeout). Direct callers + # (tests/scripts) get auto-allow so restricted mode can't hang them on a + # CLI prompt. + agent.set_permission_handler(permission_handler or AutoPermissionHandler()) + return agent diff --git a/webui/backend/app/backends/ms_agent/instructions.py b/webui/backend/app/backends/ms_agent/instructions.py new file mode 100644 index 000000000..8ee0b6219 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/instructions.py @@ -0,0 +1,57 @@ +"""Instructions adapter — global via PersonalizationSettings.global_instruction, +project via Project.instruction (ProjectManager).""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.backends.errors import BadRequest +from app.backends.ms_agent.common import home, pm +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.instruction import Instruction, InstructionUpsert + + +def _ps(): + from ms_agent.personalization import PersonalizationSettings + + return PersonalizationSettings(global_dir=home()) + + +def _parse_scope(scope: str) -> tuple[str, str | None]: + if scope == "global": + return "global", None + if scope.startswith("project:"): + pid = scope.split(":", 1)[1] + if pm().get(pid) is None: + raise BadRequest(f"unknown project: {pid}") + return "project", pid + raise BadRequest(f"invalid scope: {scope!r}") + + +def get_instruction(scope: str) -> Instruction: + kind, pid = _parse_scope(scope) + if kind == "global": + with settings_lock(): + content = _ps().load().global_instruction + else: + content = pm().get(pid).instruction or "" + return Instruction(scope=scope, content=content, updated_at=datetime.now(timezone.utc)) + + +def upsert_instruction(scope: str, body: InstructionUpsert) -> Instruction: + from ms_agent.personalization import PersonalizationConfig + + kind, pid = _parse_scope(scope) + if kind == "global": + with settings_lock(): + ps = _ps() + cur = ps.load() + ps.save( + PersonalizationConfig( + global_instruction=body.content, + memory_enabled=cur.memory_enabled, + memory_backend=cur.memory_backend, + ) + ) + else: + pm().update(pid, instruction=body.content) + return Instruction(scope=scope, content=body.content, updated_at=datetime.now(timezone.utc)) diff --git a/webui/backend/app/backends/ms_agent/mapping.py b/webui/backend/app/backends/ms_agent/mapping.py new file mode 100644 index 000000000..df17967bd --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mapping.py @@ -0,0 +1,169 @@ +"""Converters between SDK dataclasses and the WebUI pydantic schemas. + +UI-only fields (description, auto-attach, preview, ...) come from the sidecar. +pydantic coerces the SDK's ISO date strings into datetime on assignment. +""" +from __future__ import annotations + +import base64 +from datetime import datetime, timezone + +from app.backends.ms_agent import sidecar +from app.schemas.model import Model as ModelSchema +from app.schemas.project import Project as ProjectSchema +from app.schemas.provider import Provider as ProviderSchema +from app.schemas.session import Session as SessionSchema + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _memory_backend(value) -> str: + return value if value in ("file", "vector") else "file" + + +def _protocol(transport: str) -> str: + return "anthropic" if "anthropic" in (transport or "") else "openai" + + +def _generation_defaults(protocol: str, provider: str) -> dict: + """Effective default generation params the backend applies for a provider, + surfaced read-only so the settings UI can show the thinking knob. Currently + just the protocol-derived ``enable_thinking`` default (config.thinking_default + is the single source of truth).""" + from app.backends.ms_agent.config import thinking_default + + return {"extra_body": {"enable_thinking": thinking_default(protocol, provider)}} + + +def _mask(api_key: str) -> str: + if not api_key: + return "" + if len(api_key) <= 8: + return "****" + return f"{api_key[:4]}****{api_key[-4:]}" + + +def project_to_schema(project) -> ProjectSchema: + from ms_agent.project.types import DEFAULT_PROJECT_ID + + meta = sidecar.get("projects", project.id, {}) or {} + return ProjectSchema( + id=project.id, + name=project.name, + description=meta.get("description", ""), + local_path=project.path, + is_default=(project.id == DEFAULT_PROJECT_ID), + memory_enabled=bool(project.memory_enabled), + memory_backend=_memory_backend(project.memory_backend), + # Sticky flag written the first time memory is saved as enabled; an + # already-enabled project created before the flag existed counts as + # locked too (its storage is live regardless of the bookkeeping). + memory_backend_locked=bool( + meta.get("memory_backend_locked", False) + or project.memory_enabled + ), + # Project-owned memory-model group (absent on legacy projects = the + # follow-conversation defaults). + memory_llm_provider_id=(meta.get("memory_models") or {}).get("llm_provider_id"), + memory_llm_model=(meta.get("memory_models") or {}).get("llm_model"), + memory_embed_mode=( + (meta.get("memory_models") or {}).get("embed_mode") + if (meta.get("memory_models") or {}).get("embed_mode") in ("provider", "local") + else "provider" + ), + memory_embed_provider_id=(meta.get("memory_models") or {}).get("embed_provider_id"), + memory_embed_model=(meta.get("memory_models") or {}).get("embed_model"), + memory_recall_top_k=(meta.get("memory_models") or {}).get("recall_top_k"), + mcp_auto_attach=meta.get("mcp_auto_attach", True), + skill_auto_attach=meta.get("skill_auto_attach", True), + permission_mode=meta.get("permission_mode", "restricted"), + created_at=project.created_at, + ) + + +def session_to_schema(session) -> SessionSchema: + meta = sidecar.get("sessions", session.id, {}) or {} + return SessionSchema( + id=session.id, + title=session.name, + project_id=session.project_id, + updated_at=session.updated_at, + preview=meta.get("preview", ""), + category=meta.get("category", ""), + ) + + +# -- providers / models -------------------------------------------------------- + + +def builtin_provider_to_schema(spec, override: dict | None = None) -> ProviderSchema: + """A registry ProviderSpec, optionally merged with a settings.json custom + entry of the same id (how a user sets creds for a built-in provider).""" + override = override or {} + meta = sidecar.get("providers", spec.name, {}) or {} + # Honor a user's protocol override (e.g. pointing a built-in provider at + # another vendor's Anthropic-compatible endpoint); fall back to the spec's + # default transport. Mirrors base_url so the settings UI and any edit + # round-trip reflect the stored value, not the default. + protocol = (override.get("protocol") + if override.get("protocol") in ("openai", "anthropic") + else _protocol(spec.transport)) + return ProviderSchema( + id=spec.name, + kind="builtin", + name=spec.display_name or spec.name, + base_url=override.get("base_url") or spec.default_base_url, + api_key_masked=_mask(override.get("api_key", "")), + protocol=protocol, + enabled=meta.get("enabled", True), + default_generation_params=meta.get("default_generation_params", {}), + generation_defaults=_generation_defaults(protocol, spec.name), + created_at=_now(), + ) + + +def custom_provider_to_schema(pid: str, entry: dict) -> ProviderSchema: + entry = entry or {} + meta = sidecar.get("providers", pid, {}) or {} + proto = entry.get("protocol") + protocol = proto if proto in ("openai", "anthropic") else "openai" + return ProviderSchema( + id=pid, + kind="custom", + name=entry.get("name", pid), + base_url=entry.get("base_url", ""), + api_key_masked=_mask(entry.get("api_key", "")), + protocol=protocol, + enabled=meta.get("enabled", True), + default_generation_params=meta.get("default_generation_params", {}), + generation_defaults=_generation_defaults(protocol, pid), + created_at=_now(), + ) + + +def encode_model_id(provider_id: str, name: str) -> str: + raw = f"{provider_id}\x1f{name}".encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def decode_model_id(model_id: str) -> tuple[str, str]: + pad = "=" * (-len(model_id) % 4) + raw = base64.urlsafe_b64decode(model_id + pad).decode() + provider_id, name = raw.split("\x1f", 1) + return provider_id, name + + +def model_to_schema(provider_id: str, name: str) -> ModelSchema: + mid = encode_model_id(provider_id, name) + meta = sidecar.get("models", mid, {}) or {} + return ModelSchema( + id=mid, + provider_id=provider_id, + name=name, + display_name=meta.get("display_name") or name, + is_builtin=False, + advanced_params=meta.get("advanced_params", {}), + created_at=_now(), + ) diff --git a/webui/backend/app/backends/ms_agent/mcp_health.py b/webui/backend/app/backends/ms_agent/mcp_health.py new file mode 100644 index 000000000..b04912a61 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mcp_health.py @@ -0,0 +1,108 @@ +"""Async MCP health probe. + +A remote MCP whose host is reachable but whose endpoint is stale/invalid (e.g. +"Session terminated") still breaks a chat turn: the agent's connect raises and +aborts the run. A TCP check can't catch that — only a real MCP handshake can. + +This probes each enabled server (connect + initialize) with a timeout, fully +isolated in its own task so a failure/hang/anyio-teardown can't touch the chat, +and returns only the servers that initialized. Runs once per session build. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import shutil + +logger = logging.getLogger("app.ms_agent.mcp_health") + + +async def _remote_handshake(server: dict) -> bool: + """connect + initialize a remote MCP, entered/exited within THIS task so an + anyio cross-task teardown can't leak. Raises on failure.""" + url = server["url"] + transport = str(server.get("transport") or "").lower() + headers = server.get("headers") or None + from mcp import ClientSession + + if transport == "sse": + from mcp.client.sse import sse_client as connect + else: # http / streamable_http + from mcp.client.streamable_http import streamablehttp_client as connect + async with connect(url, headers=headers) as streams: + read, write = streams[0], streams[1] + async with ClientSession(read, write) as session: + await session.initialize() + return True + + +async def _probe_remote(server: dict, timeout: float) -> bool: + if not server.get("url"): + return False + try: + return bool(await asyncio.wait_for(_remote_handshake(server), timeout)) + except Exception: + return False + + +def _short_error(exc: BaseException) -> str: + """Unwrap anyio ExceptionGroups to a short, human-readable reason.""" + while isinstance(exc, BaseExceptionGroup) and exc.exceptions: + exc = exc.exceptions[0] + msg = str(exc).strip() or type(exc).__name__ + return msg[:200] + + +def _runtime_view(server: dict) -> dict: + """The server entry as the runtime will actually use it: ${VAR} + placeholders resolved from the process environment. Management surfaces + keep the placeholder form; probing with it would 401 against a healthy + server. Idempotent on already-expanded entries.""" + from ms_agent.tui.managed_config import expand_env_placeholders + + return expand_env_placeholders(server) + + +async def check_server(server: dict, timeout: float = 6.0) -> tuple[bool, str | None]: + """Probe one server and return (healthy, error_reason). Same handshake as the + build-time filter, but surfaces WHY an enabled server was dropped.""" + server = _runtime_view(server) + if server.get("command"): + ok = _probe_stdio(server) + return ok, (None if ok else "command not found on PATH") + if not server.get("url"): + return False, "server has no url or command" + try: + await asyncio.wait_for(_remote_handshake(server), timeout) + return True, None + except asyncio.TimeoutError: + return False, f"timed out after {int(timeout)}s" + except Exception as exc: # noqa: BLE001 — report the reason, don't raise + return False, _short_error(exc) + + +def _probe_stdio(server: dict) -> bool: + command = server.get("command") + if not command: + return True + return bool(shutil.which(command)) or os.path.isfile(command) + + +async def filter_healthy(servers: dict, timeout: float = 6.0) -> dict: + """Return the subset of servers that pass their probe (concurrently).""" + if not servers: + return {} + + async def _check(name: str, server: dict) -> tuple[str, bool]: + server = _runtime_view(server) + if server.get("command"): + return name, _probe_stdio(server) + return name, await _probe_remote(server, timeout) + + results = await asyncio.gather(*(_check(n, s) for n, s in servers.items())) + healthy = {name: servers[name] for name, ok in results if ok} + dropped = [name for name, ok in results if not ok] + if dropped: + logger.warning("dropping unhealthy MCP server(s): %s", ", ".join(sorted(dropped))) + return healthy diff --git a/webui/backend/app/backends/ms_agent/mcps.py b/webui/backend/app/backends/ms_agent/mcps.py new file mode 100644 index 000000000..d7b722038 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mcps.py @@ -0,0 +1,380 @@ +"""MCP adapter — MCPConfigManager (global + per-project) with heuristic mapping +between the WebUI's flat {transport, endpoint} and the SDK's structured server +dict ({command,args} stdio | {url,transport} remote). Ids encode scope+name; +description lives in the sidecar.""" +from __future__ import annotations + +import base64 +import json +import shlex +from datetime import datetime, timezone + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.mcp import Mcp, McpCreate, McpHealth, McpUpdate + + +def _encode_id(scope: str, name: str) -> str: + return base64.urlsafe_b64encode(f"{scope}\x1f{name}".encode()).decode().rstrip("=") + + +def _decode_id(mcp_id: str) -> tuple[str, str]: + try: + pad = "=" * (-len(mcp_id) % 4) + scope, name = base64.urlsafe_b64decode(mcp_id + pad).decode().split("\x1f", 1) + return scope, name + except Exception: + raise NotFound("mcp not found") + + +def _mm_for(scope: str): + """Return (MCPConfigManager, sdk_scope) for a WebUI scope string.""" + from ms_agent.config import MCPConfigManager + + if scope == "global": + return MCPConfigManager(global_root=home()), "global" + if scope.startswith("project:"): + pid = scope.split(":", 1)[1] + proj = pm().get(pid) + if proj is None: + raise BadRequest(f"unknown project: {pid}") + return MCPConfigManager(global_root=home(), project_root=proj.path), "project" + raise BadRequest(f"invalid scope: {scope!r}") + + +def _endpoint(entry: dict) -> tuple[str, str]: + """(transport, endpoint) from a structured SDK server dict.""" + if entry.get("command"): + parts = [entry["command"], *(entry.get("args") or [])] + return "stdio", shlex.join(str(p) for p in parts) + transport = entry.get("transport") or "sse" + if transport not in ("http", "sse", "streamable_http"): + transport = "sse" + if transport == "streamable_http": + transport = "http" + return transport, entry.get("url", "") + + +def _server(transport: str, endpoint: str, env: dict | None = None, headers: dict | None = None) -> dict: + if transport == "stdio": + parts = shlex.split(endpoint) + if not parts: + raise BadRequest("empty stdio command") + server = {"command": parts[0], "args": parts[1:]} + if env: + server["env"] = env + return server + server = {"url": endpoint, "transport": transport} + if headers: + server["headers"] = headers + return server + + +def _to_schema(scope: str, name: str, entry: dict) -> Mcp: + transport, endpoint = _endpoint(entry) + mid = _encode_id(scope, name) + desc = (sidecar.get("mcps", mid, {}) or {}).get("description") or entry.get("description", "") + created = (entry.get("meta") or {}).get("added_at") or datetime.now(timezone.utc) + return Mcp( + id=mid, + name=name, + description=desc, + transport=transport, + endpoint=endpoint, + enabled=entry.get("enabled", True), + scope=scope, + env=entry.get("env") or {}, + headers=entry.get("headers") or {}, + created_at=created, + ) + + +def _is_tombstone(entry: dict) -> bool: + """True for a project entry that only MASKS a server instead of defining one. + + `MCPConfigManager.remove(scope='project')` writes + `{enabled: false, _removed: true}`; normalization then strips `_removed` and + leaves an entry with no endpoint at all. Such a row is not a server — listing + it is what made a removed project MCP look merely disabled. + """ + if entry.get("_removed"): + return True + return not entry.get("url") and not entry.get("command") + + +def list_mcps(scope: str | None = None) -> list[Mcp]: + out: list[Mcp] = [] + if scope: + mm, sdk_scope = _mm_for(scope) + for name, entry in (mm.list(sdk_scope) or {}).items(): + if _is_tombstone(entry): + continue + out.append(_to_schema(scope, name, entry)) + else: + from ms_agent.config import MCPConfigManager + + gm = MCPConfigManager(global_root=home()) + for name, entry in (gm.list("global") or {}).items(): + out.append(_to_schema("global", name, entry)) + for proj in pm().list(): + pmm = MCPConfigManager(global_root=home(), project_root=proj.path) + for name, entry in (pmm.list("project") or {}).items(): + if _is_tombstone(entry): + continue + out.append(_to_schema(f"project:{proj.id}", name, entry)) + # NO re-sorting: the order IS the order of mcp.json, which is what the user + # controls by editing/reordering that file. Sorting by created_at made the + # list depend on a timestamp the SDK rewrites on edit, so toggling a server + # moved its card; it also meant a manual reorder in the JSON had no effect. + return out + + +def _enabled_entries() -> list[tuple[str, str, str, dict]]: + """(id, name, scope, server_entry) for every ENABLED MCP across scopes.""" + from ms_agent.config import MCPConfigManager + + out: list[tuple[str, str, str, dict]] = [] + gm = MCPConfigManager(global_root=home()) + for name, entry in (gm.list("global") or {}).items(): + if entry.get("enabled", True): + out.append((_encode_id("global", name), name, "global", entry)) + for proj in pm().list(): + pmm = MCPConfigManager(global_root=home(), project_root=proj.path) + for name, entry in (pmm.list("project") or {}).items(): + if entry.get("enabled", True): + out.append( + (_encode_id(f"project:{proj.id}", name), name, f"project:{proj.id}", entry) + ) + return out + + +def health() -> list[McpHealth]: + """Probe every enabled MCP (connect+initialize) and report status + reason. + On-demand only — never call from list_mcps (each probe is a network handshake + up to the timeout).""" + import asyncio + + from app.backends.ms_agent import mcp_health + + entries = _enabled_entries() + if not entries: + return [] + + async def _run(): + return await asyncio.gather( + *(mcp_health.check_server(entry) for _, _, _, entry in entries) + ) + + results = asyncio.run(_run()) + return [ + McpHealth(id=mid, name=name, scope=scope, healthy=ok, error=err) + for (mid, name, scope, _entry), (ok, err) in zip(entries, results) + ] + + +def health_one(mcp_id: str) -> McpHealth: + """Probe a single MCP server by id and return its health + error reason.""" + import asyncio + + from app.backends.ms_agent import mcp_health + + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + entry = mm.get(name, sdk_scope) + if entry is None: + raise NotFound("mcp not found") + ok, err = asyncio.run(mcp_health.check_server(entry)) + return McpHealth(id=mcp_id, name=name, scope=scope, healthy=ok, error=err) + + +def create_mcp(body: McpCreate) -> Mcp: + # Locked: every mutation here is a read-modify-write of the whole mcp file, + # and the SDK manager's own lock is per INSTANCE (a fresh one per request), + # so two threadpooled requests would otherwise load the same old file and + # each save its own partial view — silently resurrecting the other's changes. + with settings_lock(): + mm, sdk_scope = _mm_for(body.scope) + if mm.get(body.name, sdk_scope) is not None: + raise Conflict("mcp name already exists in this scope") + server = _server(body.transport, body.endpoint, body.env, body.headers) + server["enabled"] = body.enabled + mm.add(body.name, server, scope=sdk_scope) + mid = _encode_id(body.scope, body.name) + if body.description: + sidecar.merge("mcps", mid, {"description": body.description}) + entry = mm.get(body.name, sdk_scope) or server + return _to_schema(body.scope, body.name, entry) + + +def get_mcp(mcp_id: str) -> Mcp: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + entry = mm.get(name, sdk_scope) + if entry is None: + raise NotFound("mcp not found") + return _to_schema(scope, name, entry) + + +def update_mcp(mcp_id: str, body: McpUpdate) -> Mcp: + with settings_lock(): # see create_mcp: guards the read-modify-write + return _update_mcp_locked(mcp_id, body) + + +def _update_mcp_locked(mcp_id: str, body: McpUpdate) -> Mcp: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + cur = mm.get(name, sdk_scope) + if cur is None: + raise NotFound("mcp not found") + + cur_transport, cur_endpoint = _endpoint(cur) + new_name = body.name or name + if new_name != name and mm.get(new_name, sdk_scope) is not None: + raise Conflict("mcp name already exists in this scope") + transport = body.transport or cur_transport + endpoint = body.endpoint if body.endpoint is not None else cur_endpoint + env = body.env if body.env is not None else cur.get("env") + headers = body.headers if body.headers is not None else cur.get("headers") + enabled = body.enabled if body.enabled is not None else cur.get("enabled", True) + + if new_name == name and transport == cur_transport: + # In-place merge: it keeps the entry where it sits in mcp.json and keeps + # its original meta.added_at. The remove+add below moves the key to the + # END of the file and lets the SDK restamp added_at — which is why simply + # toggling a server used to reshuffle the list. + patch = _server(transport, endpoint, env, headers) + patch["enabled"] = enabled + # A merge cannot drop keys, so clearing env/headers has to be explicit. + if body.env is not None and not env: + patch["env"] = {} + if body.headers is not None and not headers: + patch["headers"] = {} + mm.update(name, patch, scope=sdk_scope) + else: + # Rename or transport switch: the entry's shape changes, so it is replaced + # wholesale — merging would leave the previous transport's keys behind + # (a stale `url` after switching to stdio). `meta` is carried over so + # added_at still says when the server was ADDED. + server = _server(transport, endpoint, env, headers) + server["enabled"] = enabled + if cur.get("meta"): + server["meta"] = cur["meta"] + mm.remove(name, sdk_scope) + mm.add(new_name, server, scope=sdk_scope) + + new_id = _encode_id(scope, new_name) + if body.description is not None: + sidecar.merge("mcps", new_id, {"description": body.description}) + if body.enabled is not None: + from app.backends.ms_agent.runtime import registry + + registry.toggle_mcp(name, body.enabled) # apply to any live session + entry = mm.get(new_name, sdk_scope) or server + return _to_schema(scope, new_name, entry) + + +def _project_owned(name: str) -> bool: + """True when `name` is defined by the PROJECT itself rather than inherited + from the global scope.""" + from ms_agent.config import MCPConfigManager + + gm = MCPConfigManager(global_root=home()) + return gm.get(name, "global") is None + + +def _hard_remove_project_entry(mm, name: str) -> None: + """Delete a project-owned server from the project's mcp.json. + + The SDK's `remove(scope='project')` always writes a MASK + (`{enabled: false, _removed: true}`) because a project may hide a global + server without deleting the global definition. For a server the project owns + there is nothing to hide, so masking made "remove" behave like "disable" — + the card stayed, just switched off. No SDK call can delete a project key, so + the file is edited directly (same shape/formatting the SDK writes). + """ + path = mm.project_mcp_path + data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {} + servers = data.get("mcpServers") + if not isinstance(servers, dict) or name not in servers: + return + del servers[name] + data["mcpServers"] = servers + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + +def replace_mcps(scope: str, bodies: list[McpCreate]) -> list[Mcp]: + """Make `scope` contain exactly `bodies`, in this order. + + For the raw-JSON editor the document IS the desired state, so this is one + atomic operation instead of the client deleting every server and re-creating + them one by one — which lost data whenever a later create was rejected (the + deletes had already landed), and could not survive two overlapping requests. + + Everything is validated BEFORE the first write, `meta` is carried over for + names that already existed (so `added_at` keeps meaning "added at"), and the + write order is the caller's order, which is what makes reordering the JSON + reorder the list. + """ + with settings_lock(): + mm, sdk_scope = _mm_for(scope) + current = mm.list(sdk_scope) or {} + + seen: set[str] = set() + built: list[tuple[str, dict, str | None]] = [] + for body in bodies: + if body.name in seen: + raise Conflict(f"duplicate mcp name: {body.name}") + seen.add(body.name) + server = _server(body.transport, body.endpoint, body.env, body.headers) + server["enabled"] = body.enabled + meta = (current.get(body.name) or {}).get("meta") + if meta: + server["meta"] = meta + built.append((body.name, server, body.description)) + + # Nothing above touched disk, so a rejected payload leaves the scope as it + # was. From here on the writes are inside the lock, hence atomic to any + # other request. + for name in list(current): + if sdk_scope == "project": + _hard_remove_project_entry(mm, name) + else: + mm.remove(name, sdk_scope) + for name, server, description in built: + mm.add(name, server, scope=sdk_scope) + mid = _encode_id(scope, name) + if description: + sidecar.merge("mcps", mid, {"description": description}) + + # Drop sidecar rows of servers that are gone for good. + for name in current: + if name not in seen: + sidecar.drop("mcps", _encode_id(scope, name)) + + return list_mcps(scope) + + +def delete_mcp(mcp_id: str) -> None: + with settings_lock(): # see create_mcp: guards the read-modify-write + _delete_mcp_locked(mcp_id) + + +def _delete_mcp_locked(mcp_id: str) -> None: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + if mm.get(name, sdk_scope) is None: + raise NotFound("mcp not found") + if sdk_scope == "project" and _project_owned(name): + _hard_remove_project_entry(mm, name) + else: + # Global: real delete. Project entry shadowing a GLOBAL server: mask it, + # so the server stays defined globally but is off for this project. + mm.remove(name, sdk_scope) + sidecar.drop("mcps", mcp_id) + from app.backends.ms_agent.runtime import registry + + registry.toggle_mcp(name, False) # disconnect from any live session diff --git a/webui/backend/app/backends/ms_agent/memory.py b/webui/backend/app/backends/ms_agent/memory.py new file mode 100644 index 000000000..914ae7915 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/memory.py @@ -0,0 +1,466 @@ +"""Per-project memory items over the SDK's unified memory. + +- ``memory_backend="file"``: items are the entry lines of + ``/.ms_agent/memory/MEMORY.md`` — the same store the chat + runtime's FileBasedBackend injects and the agent's ``memory`` tool edits. + Ids are content hashes (the file has no per-entry ids); ``updated_at`` is + the file mtime. +- ``memory_backend="vector"``: items are mem0 memories (user_id = project id, + embedded local qdrant under the project memory dir). UI writes use + ``infer=False`` so a note is stored verbatim; the agent's conversational + ingestion (fact extraction) shares the same store. The live chat runtime's + mem0 instance is reused when present — embedded qdrant is single-client. + +Guards on every entry point: the project must exist and have memory enabled. +""" +from __future__ import annotations + +import hashlib +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from app.backends.errors import BadRequest, NotFound +from app.schemas.memory import ( + MemoryDoc, + MemoryDocUpdate, + MemoryItem, + MemoryItemCreate, + MemoryItemUpdate, +) + + +def _guard(pid: str): + from app.backends.ms_agent.common import pm + + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + if not proj.memory_enabled: + raise BadRequest("memory is disabled for this project") + return proj + + +def _storage(proj): + from ms_agent.memory.unified.config import MemoryConfig + from ms_agent.memory.unified.storage.file_storage import FileMemoryStorage + from ms_agent.project.paths import memory_dir + + cfg = MemoryConfig(base_dir=str(memory_dir(proj.path))) + return FileMemoryStorage(cfg) + + +def _invalidate_live(proj) -> None: + """Drop the snapshot/content cache of any live agent sharing this store, so + a UI edit is visible to the next turn without a runtime rebuild.""" + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + target = Path(str(memory_dir(proj.path))) + for mem in list(SharedMemoryManager._instances.values()): + base = getattr(getattr(mem, "mem_config", None), "base_dir", None) + if base and Path(str(base)) == target and hasattr(mem, "invalidate_snapshot"): + mem.invalidate_snapshot() + + +def _is_vector(proj) -> bool: + return (getattr(proj, "memory_backend", None) or "file") == "vector" + + +def _mem0_result_list(res) -> list[dict]: + if isinstance(res, dict): + res = res.get("results", []) + return list(res or []) + + +# mem0 2.x's get_all defaults to top_k=20, which silently truncates a notes +# list. 1000 covers any store a person will actually accumulate; if one ever +# exceeds it, list_items logs so the truncation is at least visible. +_MEM0_LIST_LIMIT = 1000 + + +def _mem0_get_all(m0, pid: str) -> list[dict]: + """mem0 2.x: filters= + top_k; 1.x: user_id kwarg.""" + try: + res = m0.get_all(filters={"user_id": pid}, top_k=_MEM0_LIST_LIMIT) + except TypeError: + res = m0.get_all(user_id=pid) + rows = _mem0_result_list(res) + if len(rows) >= _MEM0_LIST_LIMIT: + import logging + + logging.getLogger("app.ms_agent.memory").warning( + "memory list for %s hit the %d-row cap; the UI shows a truncated " + "view", pid, _MEM0_LIST_LIMIT) + return rows + + +@contextmanager +def _mem0_for(proj): + """Yield a mem0.Memory over the project's store. + + Prefer the live chat runtime's instance (same process — embedded qdrant + holds a file lock, so a second client on the same path would fail). Build + a transient instance otherwise and close its vector client afterwards.""" + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + target = Path(str(memory_dir(proj.path))) + for mem in list(SharedMemoryManager._instances.values()): + base = getattr(getattr(mem, "mem_config", None), "base_dir", None) + backend = getattr(mem, "_backend", None) + live = getattr(backend, "_mem0", None) + if base and Path(str(base)) == target and live is not None: + yield live + return + + from app.backends.ms_agent.config import MemoryConfigError, _mem0_options + + try: + import mem0 + except Exception as exc: # pragma: no cover - import guard + raise BadRequest(f"vector memory unavailable: {exc}") + try: + options = _mem0_options(proj) + except MemoryConfigError as exc: + raise BadRequest(f"vector memory unavailable: {exc}") + try: + m0 = mem0.Memory.from_config(options) + except Exception as exc: + raise BadRequest(f"vector memory init failed: {exc}") + try: + yield m0 + finally: + try: # release the embedded qdrant lock promptly + m0.vector_store.client.close() + except Exception: + pass + + +def _vector_item(pid: str, r: dict) -> MemoryItem: + at = r.get("updated_at") or r.get("created_at") or _now() + return MemoryItem( + id=str(r.get("id") or ""), + project_id=pid, + content=str(r.get("memory") or r.get("text") or ""), + updated_at=str(at), + ) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _entry_id(line: str) -> str: + return "mem_" + hashlib.sha1(line.encode("utf-8")).hexdigest()[:12] + + +def _entries(storage) -> list[str]: + return [l.strip() for l in storage.get_content().splitlines() if l.strip()] + + +def _mtime(storage) -> str: + try: + ts = storage.memory_path.stat().st_mtime + except OSError: + return datetime.now(timezone.utc).isoformat() + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def _item(pid: str, line: str, updated_at: str) -> MemoryItem: + return MemoryItem( + id=_entry_id(line), project_id=pid, content=line, updated_at=updated_at + ) + + +def _migrate_sidecar(pid: str, proj, storage) -> None: + """One-time: fold legacy sidecar note items into MEMORY.md (pre-unified + versions kept the UI list in webui_meta.json, invisible to the agent).""" + from app.backends.ms_agent import sidecar + + legacy = list(sidecar.get("memory", pid, []) or []) + if not legacy: + return + for item in legacy: + content = str(item.get("content") or "").strip() + if content: + storage._add_entry(content) + sidecar.drop("memory", pid) + _invalidate_live(proj) + + + +def _store_lock_for(proj): + """The SDK's per-store asyncio lock for this project's memory dir. + + HTTP reads/deletes borrow the live mem0 client, so they must serialize + against background ingestion the same way agent-side retrieval does — + qdrant local is lock-free single-client code. Falls back to a no-op lock + on an SDK predating the discipline.""" + import contextlib + + from ms_agent.project.paths import memory_dir + + try: + from ms_agent.memory.unified.orchestrator import _store_lock + + return _store_lock(str(memory_dir(proj.path))) + except Exception: # pragma: no cover - older SDK + @contextlib.asynccontextmanager + async def _noop(): + yield + + return _noop() + + +async def list_items(pid: str) -> list[MemoryItem]: + proj = _guard(pid) + if _is_vector(proj): + async with _store_lock_for(proj): + with _mem0_for(proj) as m0: + rows = _mem0_get_all(m0, pid) + items = [_vector_item(pid, r) for r in rows] + return [i for i in items if i.content] + storage = _storage(proj) + _migrate_sidecar(pid, proj, storage) + at = _mtime(storage) + # File order == MEMORY.md order (what the agent reads). + return [_item(pid, line, at) for line in _entries(storage)] + + +def create_item(pid: str, body: MemoryItemCreate) -> MemoryItem: + proj = _guard(pid) + content = (body.content or "").strip() + if not content: + raise BadRequest("memory content is empty") + if _is_vector(proj): + # Vector memories are written by the agent's own fact extraction during + # conversation; hand-authoring them is not offered (the UI has no such + # affordance either). Removing a wrong one stays allowed. + raise BadRequest( + "vector memories are written by the agent; " + "they cannot be created manually") + storage = _storage(proj) + if not storage._add_entry(content): + raise BadRequest("memory is full (char budget) — remove entries first") + _invalidate_live(proj) + return _item(pid, content, _mtime(storage)) + + +def update_item(pid: str, item_id: str, body: MemoryItemUpdate) -> MemoryItem: + proj = _guard(pid) + content = (body.content or "").strip() + if not content: + raise BadRequest("memory content is empty") + if _is_vector(proj): + # Read-only apart from deletion — see create_item. + raise BadRequest( + "vector memories are written by the agent; " + "they cannot be edited manually") + storage = _storage(proj) + old = next((l for l in _entries(storage) if _entry_id(l) == item_id), None) + if old is None: + raise NotFound("memory item not found") + if content != old and not storage.replace_entry(old, content): + raise BadRequest("memory update rejected (char budget or security scan)") + _invalidate_live(proj) + return _item(pid, content, _mtime(storage)) + + +async def delete_item(pid: str, item_id: str) -> None: + proj = _guard(pid) + if _is_vector(proj): + async with _store_lock_for(proj): + with _mem0_for(proj) as m0: + try: + m0.delete(memory_id=item_id) + except Exception as exc: + if "not found" in str(exc).lower() or isinstance(exc, IndexError): + raise NotFound("memory item not found") + raise BadRequest(f"vector memory delete failed: {exc}") + _invalidate_live(proj) + return + storage = _storage(proj) + old = next((l for l in _entries(storage) if _entry_id(l) == item_id), None) + if old is None: + raise NotFound("memory item not found") + storage.remove_entry(old) + _invalidate_live(proj) + + +# ── status & rebuild (vector backend health surface) ────────────────────── + + +def get_status(pid: str): + """What the memory subsystem is actually doing for this project: the + resolved embedder identity, why vector memory is unusable if it is, and + the last ingest outcome of any live runtime. This is how config problems + stop being silent — the card renders this instead of an empty list.""" + from app.backends.ms_agent.config import ( + MemoryConfigError, + _load_embedder_identity, + _local_embed_available, + _project_memory_models, + _read_settings, + _resolve_embedder, + ) + from app.schemas.memory import ( + MemoryEmbedderInfo, + MemoryErrorInfo, + MemoryIngestInfo, + MemoryStatus, + ) + + proj = _guard(pid) + if not _is_vector(proj): + return MemoryStatus(project_id=pid, backend="file", + local_embed_available=_local_embed_available()) + + embedder = None + error = None + try: + desc = _resolve_embedder(_read_settings(), _project_memory_models(proj)) + identity = _load_embedder_identity(proj) + current = (desc.get("provider") or "local", desc["model"]) + if identity is not None and ( + identity.get("provider"), identity.get("model")) != current: + error = MemoryErrorInfo( + code="embedder_mismatch", + message=( + f"store built with {identity.get('provider')}/" + f"{identity.get('model')}, current embedder is " + f"{current[0]}/{current[1]}")) + embedder = MemoryEmbedderInfo( + mode="local" if identity.get("provider") == "local" else "provider", + provider=identity.get("provider"), + model=identity.get("model"), + dimension=identity.get("dimension")) + else: + embedder = MemoryEmbedderInfo( + mode=desc["mode"], + provider=desc.get("provider"), + model=desc["model"], + dimension=(identity or {}).get("dimension"), + fallback_reason=desc.get("fallback_reason")) + except MemoryConfigError as exc: + error = MemoryErrorInfo(code=exc.code, message=str(exc)) + + ingest = None + status = _live_ingest_status(proj) + if status is not None: + ingest = MemoryIngestInfo( + state=str(status.get("state") or "idle"), + at=status.get("at"), + count=status.get("count"), + error=status.get("error"), + pending=int(status.get("pending") or 0)) + + return MemoryStatus( + project_id=pid, backend="vector", embedder=embedder, error=error, + ingest=ingest, local_embed_available=_local_embed_available()) + + +def _live_ingest_status(sdk_proj) -> dict | None: + """The shared orchestrator's last ingest outcome, if one is live.""" + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + target = Path(str(memory_dir(sdk_proj.path))) + for mem in list(SharedMemoryManager._instances.values()): + base = getattr(getattr(mem, "mem_config", None), "base_dir", None) + if base and Path(str(base)) == target: + status = getattr(mem, "ingest_status", None) + if isinstance(status, dict): + return status + return None + + +async def rebuild(pid: str): + """Start the vector store over with the CURRENT embedder. + + The old store is moved aside (``qdrant.bak-``), never deleted — this + is the remedy the embedder-mismatch error points at, and a remedy that + destroys data must not be one click. The ingest ledger is cleared too, so + the next completed turn re-ingests the session's context into the fresh + store. Async on purpose: closing the live orchestrator must run on the + app loop, where its pending ingest tasks live.""" + import shutil + import time as _time + + from app.backends.ms_agent.config import _embedder_identity_path + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + proj = _guard(pid) + if not _is_vector(proj): + raise BadRequest("memory rebuild only applies to the vector backend") + mem_dir = Path(str(memory_dir(proj.path))) + + # Release the embedded store first — a live runtime holds its exclusive + # file lock, and moving a locked qdrant dir out from under it corrupts + # the client's view. + await SharedMemoryManager.close_matching(str(mem_dir)) + + qdrant = mem_dir / "qdrant" + if qdrant.exists(): + backup = mem_dir / f"qdrant.bak-{_time.strftime('%Y%m%d-%H%M%S')}" + shutil.move(str(qdrant), str(backup)) + for stale in (_embedder_identity_path(proj), mem_dir / "ingest_state.json"): + try: + Path(str(stale)).unlink(missing_ok=True) + except OSError: + pass + return get_status(pid) + + +# ── file backend: the whole document ────────────────────────────────────── +# With memory_backend="file", memory IS one markdown file the agent reads +# (MEMORY.md). The UI previews/edits it as a document, so these two functions +# expose it wholesale instead of line-by-line. Vector projects have no such +# file and are rejected. + +def _require_file_backend(proj): + if _is_vector(proj): + raise BadRequest( + "memory document is only available for the file backend") + + +def get_doc(pid: str) -> MemoryDoc: + proj = _guard(pid) + _require_file_backend(proj) + storage = _storage(proj) + _migrate_sidecar(pid, proj, storage) + return MemoryDoc( + project_id=pid, + content=storage.get_content(), + updated_at=_mtime(storage), + ) + + +def put_doc(pid: str, body: MemoryDocUpdate) -> MemoryDoc: + proj = _guard(pid) + _require_file_backend(proj) + storage = _storage(proj) + # Go through the storage object rather than writing the path directly: this + # document is dumped into the system prompt in full on every turn, so it has + # to obey the same char budget and security scan the agent's own `memory` + # tool does. full_replace() applies both (over-budget content is truncated, + # not silently accepted). Then drop live agents' caches, as item edits do. + path = Path(str(storage.memory_path)) + path.parent.mkdir(parents=True, exist_ok=True) + text = body.content or "" + if text and not text.endswith("\n"): + text += "\n" + # full_replace() truncates over-budget content, which is right for the LLM + # consolidation path it was written for but wrong here: a person pressed + # Save, so tell them instead of quietly dropping the tail. + if len(text) > storage.char_limit: + raise BadRequest( + f"memory is too long ({len(text)} chars, limit {storage.char_limit}) " + "— it is injected into the prompt in full on every turn") + if not storage.full_replace(text): + raise BadRequest("memory update rejected (security scan)") + _invalidate_live(proj) + return MemoryDoc( + project_id=pid, content=storage.get_content(), updated_at=_mtime(storage) + ) diff --git a/webui/backend/app/backends/ms_agent/model_link.py b/webui/backend/app/backends/ms_agent/model_link.py new file mode 100644 index 000000000..7849d625d --- /dev/null +++ b/webui/backend/app/backends/ms_agent/model_link.py @@ -0,0 +1,130 @@ +"""Keep the model link coherent. + +Three things must agree for the UI to work: + * chat's active credentials -> settings.json `llm` block (what ConfigResolver reads) + * the default model -> settings.json `default_model` = "provider/model" + * the model catalog -> settings.json `providers[p].models` (what /api/models lists) + +Selecting a model in the UI sends a base64 Model.id (provider+name); this module +decodes it, points the llm block + default_model at it, and makes sure it's in +the catalog. Credential precedence: provider override -> current llm block (same +provider) -> built-in registry default. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from app.backends.ms_agent.common import home +from app.backends.ms_agent.settings_store import settings_lock + + +def _path() -> Path: + return Path(home()) / "settings.json" + + +def _load_unlocked() -> dict: + p = _path() + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def _load() -> dict: + with settings_lock(): + return _load_unlocked() + + +def _save_unlocked(data: dict) -> None: + p = _path() + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + + +def _save(data: dict) -> None: + with settings_lock(): + _save_unlocked(data) + + +def _registry_base_url(provider: str) -> str: + try: + from ms_agent.llm.spec import get_registry + + for spec in get_registry().list_providers(): + if spec.name == provider: + return spec.default_base_url or "" + except Exception: + pass + return "" + + +def active_model(data: dict | None = None) -> tuple[str | None, str | None]: + """(provider, model) of the currently-active model, best-effort.""" + data = data if data is not None else _load() + dm = data.get("default_model") + if dm and "/" in dm: + provider, model = dm.split("/", 1) + return provider, model + llm = data.get("llm", {}) or {} + if dm: # bare model name — infer its provider from the catalog or the llm block + for p, v in (data.get("providers", {}) or {}).items(): + if dm in ((v or {}).get("models") or []): + return p, dm + return llm.get("provider"), dm + if llm.get("provider") and llm.get("model"): + return llm.get("provider"), llm.get("model") + return None, None + + +def set_active_model(provider: str, model: str) -> None: + """Point the llm block + default_model at (provider, model) and ensure the + model is in the provider's catalog. Preserves working credentials.""" + with settings_lock(): + data = _load_unlocked() + llm = data.get("llm", {}) or {} + prov_entry = (data.get("providers", {}) or {}).get(provider, {}) or {} + same_provider = llm.get("provider") == provider + + if "api_key" in prov_entry: + api_key = prov_entry.get("api_key") or "" + else: + api_key = (llm.get("api_key") if same_provider else "") or "" + if "base_url" in prov_entry: + base_url = prov_entry.get("base_url") or _registry_base_url(provider) + else: + base_url = (llm.get("base_url") if same_provider else "") or _registry_base_url(provider) + + new_llm = {"provider": provider, "model": model} + if api_key: + new_llm["api_key"] = api_key + if base_url: + new_llm["base_url"] = base_url + data["llm"] = new_llm + data["default_model"] = f"{provider}/{model}" + + prov = data.setdefault("providers", {}).setdefault(provider, {}) + prov.setdefault("protocol", "openai") + if api_key and not prov.get("api_key"): + prov["api_key"] = api_key + if base_url and not prov.get("base_url"): + prov["base_url"] = base_url + models = prov.setdefault("models", []) + if model and model not in models: + models.append(model) + + _save_unlocked(data) + + +def ensure_link() -> None: + """Normalize on boot: default_model -> 'provider/model' and the active model + registered in the catalog, so the chat dropdown is never empty for a + configured model.""" + provider, model = active_model() + if provider and model: + set_active_model(provider, model) diff --git a/webui/backend/app/backends/ms_agent/models.py b/webui/backend/app/backends/ms_agent/models.py new file mode 100644 index 000000000..d93936fb2 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/models.py @@ -0,0 +1,92 @@ +"""Models adapter — models live as string lists inside settings.json providers. + +A synthetic id encodes (provider_id, name); display_name / advanced_params (not +modelled by the SDK) live in the sidecar keyed by that id.""" +from __future__ import annotations + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import ( + decode_model_id, + encode_model_id, + model_to_schema, +) +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.model import Model, ModelCreate, ModelUpdate + + +def _msm(): + from ms_agent.config.model_settings import ModelSettingsManager + + return ModelSettingsManager(global_dir=home()) + + +def _builtin_ids() -> set[str]: + from ms_agent.llm.spec import get_registry + + return {s.name for s in get_registry().list_providers()} + + +def _model_names(provider_id: str) -> list[str]: + with settings_lock(): + return _msm().list_custom_providers().get(provider_id, {}).get("models", []) + + +def list_models(provider_id: str | None = None) -> list[Model]: + with settings_lock(): + custom = _msm().list_custom_providers() + out: list[Model] = [] + for pid, entry in custom.items(): + if provider_id and pid != provider_id: + continue + for name in entry.get("models", []): + out.append(model_to_schema(pid, name)) + return out + + +def create_model(body: ModelCreate) -> Model: + with settings_lock(): + msm = _msm() + if body.provider_id not in msm.list_custom_providers() and body.provider_id not in _builtin_ids(): + raise BadRequest("unknown provider") + msm.add_model(body.provider_id, body.name) + mid = encode_model_id(body.provider_id, body.name) + side = {} + if body.display_name: + side["display_name"] = body.display_name + if body.advanced_params: + side["advanced_params"] = body.advanced_params + if side: + sidecar.merge("models", mid, side) + return model_to_schema(body.provider_id, body.name) + + +def _decode(model_id: str) -> tuple[str, str]: + try: + return decode_model_id(model_id) + except Exception: + raise NotFound("model not found") + + +def update_model(model_id: str, body: ModelUpdate) -> Model: + provider_id, name = _decode(model_id) + if name not in _model_names(provider_id): + raise NotFound("model not found") + side = {} + if body.display_name is not None: + side["display_name"] = body.display_name + if body.advanced_params is not None: + side["advanced_params"] = body.advanced_params + if side: + sidecar.merge("models", model_id, side) + return model_to_schema(provider_id, name) + + +def delete_model(model_id: str) -> None: + provider_id, name = _decode(model_id) + with settings_lock(): + if name not in _msm().list_custom_providers().get(provider_id, {}).get("models", []): + raise NotFound("model not found") + _msm().remove_model(provider_id, name) + sidecar.drop("models", model_id) diff --git a/webui/backend/app/backends/ms_agent/profile.py b/webui/backend/app/backends/ms_agent/profile.py new file mode 100644 index 000000000..4706ebbaf --- /dev/null +++ b/webui/backend/app/backends/ms_agent/profile.py @@ -0,0 +1,31 @@ +"""Profile adapter — description via ProfileManager (profile.md), the structured +agent_calls_user field via sidecar.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home +from app.schemas.profile import Profile, ProfileUpsert + + +def _pm(): + from ms_agent.personalization import ProfileManager + + return ProfileManager(global_dir=home()) + + +def get_profile() -> Profile: + return Profile( + agent_calls_user=sidecar.get("profile", "agent_calls_user", "User"), + description=_pm().read(), + updated_at=datetime.now(timezone.utc), + ) + + +def update_profile(body: ProfileUpsert) -> Profile: + if body.description is not None: + _pm().write(body.description) + if body.agent_calls_user is not None: + sidecar.put("profile", "agent_calls_user", body.agent_calls_user) + return get_profile() diff --git a/webui/backend/app/backends/ms_agent/projects.py b/webui/backend/app/backends/ms_agent/projects.py new file mode 100644 index 000000000..612868789 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/projects.py @@ -0,0 +1,193 @@ +"""Projects adapter — ProjectManager + sidecar (description / auto-attach).""" +from __future__ import annotations + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.backends.ms_agent.mapping import _memory_backend, project_to_schema +from app.schemas.project import ( + MEMORY_MODEL_FIELDS, + Project, + ProjectCreate, + ProjectUpdate, +) + + +def _memory_defaults() -> tuple[bool, str]: + """New-project memory defaults come from the global personalization block + (what the agent-settings page writes).""" + from ms_agent.personalization import PersonalizationSettings + + cfg = PersonalizationSettings(global_dir=home()).load() + return bool(cfg.memory_enabled), (cfg.memory_backend or "file") + + +def _is_default(pid: str) -> bool: + from ms_agent.project.types import DEFAULT_PROJECT_ID + + return pid == DEFAULT_PROJECT_ID + + +def list_projects() -> list[Project]: + projects = pm().list() + # Plain creation order: the default project is an ordinary project here, so + # it takes whatever position its own created_at gives it. + projects.sort(key=lambda p: p.created_at) + return [project_to_schema(p) for p in projects] + + +def _memory_models_from(body, defaults: dict) -> dict: + """The project's memory-model group: the body's values when the group was + sent, else the global defaults — materialized ONCE here so later changes + to the global defaults never touch this project.""" + sent = bool(set(MEMORY_MODEL_FIELDS) & body.model_fields_set) + if sent: + mode = body.memory_embed_mode + else: + mode = defaults.get("embed_mode") + return { + "llm_provider_id": body.memory_llm_provider_id if sent else defaults.get("llm_provider_id"), + "llm_model": body.memory_llm_model if sent else defaults.get("llm_model"), + "embed_mode": mode if mode in ("provider", "local") else "provider", + "embed_provider_id": body.memory_embed_provider_id if sent else defaults.get("embed_provider_id"), + "embed_model": body.memory_embed_model if sent else defaults.get("embed_model"), + "recall_top_k": body.memory_recall_top_k if sent else defaults.get("recall_top_k"), + } + + +def create_project(body: ProjectCreate) -> Project: + manager = pm() + default_enabled, default_backend = _memory_defaults() + mem_enabled = body.memory_enabled if body.memory_enabled is not None else default_enabled + mem_backend = body.memory_backend if body.memory_backend is not None else default_backend + + if body.local_path: + # "use an existing folder": path is identity, dedups on reopen. + proj = manager.open_folder( + path=body.local_path, + name=body.name, + memory_enabled=mem_enabled, + memory_backend=mem_backend, + ) + else: + proj = manager.create( + name=body.name, + memory_enabled=mem_enabled, + memory_backend=mem_backend, + # The runtime writes products directly under the project dir, so the + # extra `workspace/` subdir is unused clutter — don't create it. + init_workspace=False, + ) + side: dict = { + "memory_models": _memory_models_from( + body, sidecar.get("agent_settings", "memory_models", {}) or {}) + } + if body.description: + side["description"] = body.description + if mem_enabled: + # Created with memory on: its storage is live, so freeze the backend + # from the start (same rule as enabling it later). + side["memory_backend_locked"] = True + sidecar.merge("projects", proj.id, side) + return project_to_schema(proj) + + +def get_project(pid: str) -> Project: + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + return project_to_schema(proj) + + +def _backend_locked(proj) -> bool: + """Has this project ever had memory saved as enabled? + + The memory backend decides the on-disk storage layout, so once storage is + live the choice is frozen — switching it would orphan what is already + stored. Currently-enabled counts as locked even without the sidecar flag, + which covers projects created before the flag was introduced. + """ + meta = sidecar.get("projects", proj.id, {}) or {} + return bool(meta.get("memory_backend_locked", False) + or proj.memory_enabled) + + +def update_project(pid: str, body: ProjectUpdate) -> Project: + manager = pm() + proj = manager.get(pid) + if proj is None: + raise NotFound("project not found") + + locked = _backend_locked(proj) + if (body.memory_backend is not None + and body.memory_backend != _memory_backend(proj.memory_backend) + and locked): + raise BadRequest( + "memory backend cannot be changed once memory has been enabled") + + # The project directory is its identity and holds all of its data. The SDK's + # update() only rewrites the `path` field — it does not move anything on + # disk — so accepting a change here would leave the project pointing at a + # directory that has none of its sessions/workspace/memory. Re-sending the + # unchanged value is fine (the edit form submits the whole shape). + if (body.local_path is not None + and body.local_path != (proj.path or "")): + raise BadRequest("project path cannot be changed after creation") + + fields: dict = {} + if body.name is not None: + fields["name"] = body.name + if body.memory_enabled is not None: + fields["memory_enabled"] = body.memory_enabled + if body.memory_backend is not None and not locked: + fields["memory_backend"] = body.memory_backend + if fields: + proj = manager.update(pid, **fields) + + side = { + k: getattr(body, k) + for k in ( + "description", + "mcp_auto_attach", + "skill_auto_attach", + "permission_mode", + ) + if getattr(body, k) is not None + } + # Memory-model group replaces as a whole when any of it was sent (the + # modal owns the section and always sends all five together). + if set(MEMORY_MODEL_FIELDS) & body.model_fields_set: + mode = body.memory_embed_mode + side["memory_models"] = { + "llm_provider_id": body.memory_llm_provider_id, + "llm_model": body.memory_llm_model, + "embed_mode": mode if mode in ("provider", "local") else "provider", + "embed_provider_id": body.memory_embed_provider_id, + "embed_model": body.memory_embed_model, + "recall_top_k": body.memory_recall_top_k, + } + # Enabling memory freezes the backend from here on — record it so the lock + # survives the user turning memory back off. + if body.memory_enabled: + side["memory_backend_locked"] = True + if side: + sidecar.merge("projects", pid, side) + if body.permission_mode is not None: + # Hot-apply to every live runtime of this project so the very next + # tool call obeys the new mode — no agent rebuild, no turn restart. + from app.backends.ms_agent.runtime import registry + + registry.set_project_permission_mode(pid, body.permission_mode) + return project_to_schema(proj) + + +def delete_project(pid: str) -> None: + manager = pm() + proj = manager.get(pid) + if proj is None: + raise NotFound("project not found") + if _is_default(pid): + raise BadRequest("cannot delete default project") + manager.delete(pid) # removes the project dir incl. its sessions + sidecar.drop("projects", pid) + sidecar.drop("memory", pid) diff --git a/webui/backend/app/backends/ms_agent/providers.py b/webui/backend/app/backends/ms_agent/providers.py new file mode 100644 index 000000000..6fab99f4e --- /dev/null +++ b/webui/backend/app/backends/ms_agent/providers.py @@ -0,0 +1,158 @@ +"""Providers adapter — ModelSettingsManager + registry + sidecar. + +Built-ins come from the read-only registry; customs from settings.json +`providers`. A custom entry whose id equals a built-in id is a credential +override and is merged into that built-in row (kept as a single entry).""" +from __future__ import annotations + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent import model_link, sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import ( + builtin_provider_to_schema, + custom_provider_to_schema, + encode_model_id, +) +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.provider import Provider, ProviderCreate, ProviderUpdate + + +def _protocol(transport: str) -> str: + return "anthropic" if "anthropic" in (transport or "") else "openai" + + +def _msm(): + from ms_agent.config.model_settings import ModelSettingsManager + + return ModelSettingsManager(global_dir=home()) + + +def _specs(): + from ms_agent.llm.spec import get_registry + + return get_registry().list_providers() + + +def _builtin_ids() -> set[str]: + return {s.name for s in _specs()} + + +def list_providers() -> list[Provider]: + with settings_lock(): + custom = _msm().list_custom_providers() + builtin_ids = _builtin_ids() + out = [builtin_provider_to_schema(s, custom.get(s.name)) for s in _specs()] + out += [ + custom_provider_to_schema(pid, entry) for pid, entry in custom.items() + if pid not in builtin_ids + ] + return out + + +def get_provider(pid: str) -> Provider: + with settings_lock(): + custom = _msm().list_custom_providers() + if pid in _builtin_ids(): + spec = next(s for s in _specs() if s.name == pid) + return builtin_provider_to_schema(spec, custom.get(pid)) + if pid in custom: + return custom_provider_to_schema(pid, custom[pid]) + raise NotFound("provider not found") + + +def create_provider(body: ProviderCreate) -> Provider: + with settings_lock(): + msm = _msm() + if body.id in _builtin_ids() or body.id in msm.list_custom_providers(): + raise Conflict("provider id already exists") + msm.add_provider( + body.id, + name=body.name, + protocol=body.protocol, + base_url=body.base_url or None, + models=[], + ) + custom = msm.list_custom_providers().get(body.id, {}) + if body.default_generation_params: + sidecar.merge( + "providers", + body.id, + {"default_generation_params": body.default_generation_params}, + ) + return custom_provider_to_schema(body.id, custom) + + +def update_provider(pid: str, body: ProviderUpdate) -> Provider: + with settings_lock(): + msm = _msm() + custom = msm.list_custom_providers() + if pid not in _builtin_ids() and pid not in custom: + raise NotFound("provider not found") + + cur = custom.get(pid, {}) + settings_changed = any(v is not None + for v in (body.name, body.protocol, + body.base_url, body.api_key)) + if settings_changed: + msm.add_provider( + pid, + name=body.name if body.name is not None else cur.get("name"), + protocol=(body.protocol if body.protocol is not None else + cur.get("protocol")) or "openai", + api_key=body.api_key + if body.api_key is not None else cur.get("api_key"), + base_url=body.base_url + if body.base_url is not None else cur.get("base_url"), + models=cur.get("models", []), + ) + active_provider, active_model = model_link.active_model() + if active_provider == pid and active_model: + model_link.set_active_model(pid, active_model) + side = {} + if body.enabled is not None: + side["enabled"] = body.enabled + if body.default_generation_params is not None: + side["default_generation_params"] = body.default_generation_params + if side: + sidecar.merge("providers", pid, side) + return get_provider(pid) + + +def delete_provider(pid: str) -> None: + with settings_lock(): + msm = _msm() + custom = msm.list_custom_providers() + if pid not in custom: + if pid in _builtin_ids(): + raise BadRequest("cannot delete builtin provider") + raise NotFound("provider not found") + model_names = list(custom.get(pid, {}).get("models", [])) + msm.remove_provider(pid) + for name in model_names: + sidecar.drop("models", encode_model_id(pid, name)) + sidecar.drop("providers", pid) + + +def get_provider_secret(pid: str) -> tuple[str, str, str]: + """Return (base_url, protocol, plaintext api_key) for model discovery. + + custom: read the plaintext api_key from settings.json; builtin: spec + default base_url + protocol, with any credential override's api_key. + Raises NotFound if the provider does not exist. + """ + with settings_lock(): + custom = _msm().list_custom_providers() + if pid in _builtin_ids(): + spec = next(s for s in _specs() if s.name == pid) + override = custom.get(pid, {}) or {} + base_url = override.get("base_url") or spec.default_base_url or "" + protocol = _protocol(spec.transport) + return base_url, protocol, override.get("api_key", "") or "" + if pid in custom: + entry = custom[pid] or {} + protocol = entry.get("protocol") + protocol = protocol if protocol in ("openai", + "anthropic") else "openai" + return entry.get("base_url", "") or "", protocol, entry.get( + "api_key", "") or "" + raise NotFound("provider not found") diff --git a/webui/backend/app/backends/ms_agent/runtime.py b/webui/backend/app/backends/ms_agent/runtime.py new file mode 100644 index 000000000..60cb3f73c --- /dev/null +++ b/webui/backend/app/backends/ms_agent/runtime.py @@ -0,0 +1,590 @@ +"""Per-session live-agent registry (Route A). + +Each SDK session owns one long-lived ``LLMAgent`` whose ``run(None)`` loop runs +in a background task, pulling prompts from an input queue and emitting structured +events to a sink. A POST enqueues one user turn and drains the sink until the +turn completes. One turn per session at a time (``turn_lock``). +""" +from __future__ import annotations + +import asyncio +import logging +import os +import time + +from app.backends.ms_agent.config import build_agent + +logger = logging.getLogger("app.ms_agent.runtime") + +# Turn lifecycle (product decision, aligned with the frontend team): a running +# turn is NEVER stopped by a client going away — navigation, refresh AND a +# fully closed browser all leave it running to completion in the background +# (the SessionLog persists the answer; the viewer re-attaches or reloads it +# later). The ONLY thing that cancels a turn is the explicit Stop button +# (POST /api/chat/interrupt). POST /api/presence remains as a running-state +# poll that drives the sidebar spinners / re-attach, not a liveness contract. + +# Internal sentinels pushed onto the turn queue (not AgentEvents): +# TURN_END — the agent called read_prompt, i.e. the current turn's answer +# is fully streamed and it is waiting for the next input. This +# is the reliable Route-A turn delimiter (turn_completed fires +# per-round and is mistimed — it emits only after the *next* +# input arrives, see llm_agent.py:1591 vs :1617). +# DRIVER_* — the agent loop stopped (error / EOF / cancellation). +TURN_END = "__turn_end__" +DRIVER_ERROR = "__driver_error__" +DRIVER_DONE = "__driver_done__" + + +class WebInputSource: + """InputSource whose read_prompt blocks on a queue fed by POST /api/chat. + + The first read is the initial prompt (session start / resume next-turn) and + marks no boundary; every later read means the previous turn finished, so it + pushes a TURN_END sentinel to the active turn queue before blocking. + + A queue item is either the prompt string, or ``(prompt, marker)`` where + ``marker`` is a display-only skill-invocation record. The marker is written + here — immediately before the SDK appends the (expanded) user row — so its + seq precedes that row's, letting history replay show the user's original + text instead of the expanded skill prompt. ``log_getter`` is lazy because + the session log is created when the agent's run loop starts.""" + + def __init__(self, queue: "asyncio.Queue", sink: "QueueEventSink", + log_getter=None) -> None: + self._queue = queue + self._sink = sink + self._log_getter = log_getter + self._first = True + + async def read_prompt(self, prompt: str = ">>> ") -> str: + if self._first: + self._first = False + else: + self._sink.push({"type": TURN_END}) + item = await self._queue.get() + text, marker = item if isinstance(item, tuple) else (item, None) + if marker and self._log_getter is not None: + try: + log = self._log_getter() + if log is not None and hasattr(log, "record_skill_invocation"): + log.record_skill_invocation(marker) + except Exception: # display-only; never block the turn + logger.debug("skill invocation marker skipped", exc_info=True) + return text + + +class QueueEventSink: + """AgentEventSink as a broadcast log of the CURRENT turn's events. + + push() appends to an in-memory list (cheap — it is on the token hot path) + and pulses an Event; any number of consumers read cursor-style via + next_event(). This is what makes late re-attach possible: a viewer who + navigates back to a running session replays the buffer from index 0 (full + catch-up of the in-flight turn) and then follows the live tail, while the + original consumer/drain keeps its own cursor. new_turn() resets the buffer + at each turn start (the previous turn's consumers have all seen their + terminal marker by then — the turn_lock guarantees turns don't overlap). + + Events are stamped with a monotonic ``_ts`` so replayed reasoning keeps its + real elapsed time instead of the replay instant. + """ + + def __init__(self) -> None: + self._events: list[dict] = [] + self._pulse = asyncio.Event() # single persistent event: set on append/swap + + def new_turn(self) -> None: + # Fresh list (not clear()) so a straggler consumer from the previous + # turn detects the swap (its captured list stops being current) instead + # of replaying the new turn; the pulse wakes such stragglers. + self._events = [] + self._pulse.set() + + def push(self, payload: dict) -> None: + self._events.append({**payload, "_ts": time.monotonic()}) + self._pulse.set() + + async def next_event(self, pos: int) -> tuple[dict, int]: + """The event at cursor ``pos`` (waiting for it if not produced yet). + + Returns a synthesized TURN_END when the buffer was swapped by + new_turn() — the consumer belongs to a finished turn and must wind + down. Multi-consumer safe: every waiter re-checks after each pulse.""" + events = self._events + while pos >= len(events): + if events is not self._events: + return {"type": TURN_END}, pos + self._pulse.clear() + if pos < len(events) or events is not self._events: + continue + await self._pulse.wait() + return events[pos], pos + 1 + + @property + def size(self) -> int: + return len(self._events) + + def emit(self, event) -> None: # ms_agent.ui.events.AgentEventSink + try: + self.push(event.to_dict()) + except Exception: # never let a renderer error break the agent loop + logger.debug("event emit dropped", exc_info=True) + + +class _PermissionEmitter: + """WebPermissionHandler EventEmitter: forwards its raw `permission_request` + dict straight onto the turn queue (it is not an AgentEvent, so it must not + go through QueueEventSink.emit's to_dict()).""" + + def __init__(self, sink: "QueueEventSink") -> None: + self._sink = sink + + def emit(self, event: dict) -> None: + self._sink.push(event) + + +def _persisting_permission_handler(sink, session_log_getter): + """WebPermissionHandler that also persists each resolved authorization to the + session log (``record_permission``), so history can replay the card in its + approved/rejected state. The record is display-only (filtered out of the LLM + context by SessionLog). ``session_log_getter`` is called lazily at ask() time + because the log is created when the agent's run loop starts (after this + handler is built). + + It also ANNOUNCES a refusal (``permission_resolved``): the SDK's ask() returns + DENY silently on timeout, so without this the only hint reaching live viewers + is the gated call's errored result — the card sat there offering buttons for a + decision that had already been made for it.""" + from ms_agent.permission.handler import ( + PermissionAction, + WebPermissionHandler, + ) + + class _PersistingWebPermissionHandler(WebPermissionHandler): + async def ask(self, tool_name, tool_args, context, suggestions=None, + call_id=""): + response = await super().ask( + tool_name, tool_args, context, suggestions, call_id=call_id) + try: + log = session_log_getter() + if log is not None and hasattr(log, "record_permission"): + state = ( + "rejected" + if response.action == PermissionAction.DENY + else "approved" + ) + # Persist the gating tool_call's id so history replay pairs + # this decision to the exact call — robust when a round + # fires several identical tool calls in parallel (args alone + # can't disambiguate). Empty when the adapter hadn't assigned + # an id yet; reconstruct then falls back to arg matching. + log.record_permission({ + "tool_name": tool_name, + "arguments": tool_args, + "state": state, + "call_id": str(call_id or ""), + }) + except Exception: # never let persistence break the turn + logger.debug("permission record skipped", exc_info=True) + if response.action == PermissionAction.DENY: + # Announce the refusal NOW. Covers both ways one happens: the + # 120s timeout (no client action at all) and a deny clicked in + # ANOTHER tab. Carries no request_id — the decision is final, so + # the card must render its rejected state, not live buttons. The + # frontend merges it onto the ask by call_id. + sink.push({ + "type": "permission_resolved", + "call_id": str(call_id or ""), + "tool_name": tool_name, + "tool_args": tool_args, + "state": "rejected", + }) + return response + + return _PersistingWebPermissionHandler(_PermissionEmitter(sink)) + + +class SessionRuntime: + def __init__(self, project, session, mcp_config: dict | None = None) -> None: + from app.backends.ms_agent import model_link + + self.project = project + self.session = session + # (provider, model) baked into this agent — the resolver reads it from + # settings.json.llm, so a later model switch is detected by comparing + # against active_model() and triggers a rebuild (see RuntimeRegistry.get). + self.model_key = model_link.active_model() + self.input_queue: "asyncio.Queue[str]" = asyncio.Queue() + self.sink = QueueEventSink() + self.input_source = WebInputSource( + self.input_queue, self.sink, + log_getter=lambda: getattr(self.agent, "session_log", None), + ) + self.turn_lock = asyncio.Lock() + # Count of live SSE viewers (the original /api/chat stream plus any + # /api/chat/attach viewers). Diagnostic state: a background-continued + # turn has zero watchers. Nothing cancels a turn based on this — only + # the explicit Stop button ends a turn early. + self.watchers = 0 + # Restricted-mode asks suspend on this handler until the frontend + # answers via POST /api/chat/permission (deny after its timeout); the + # decision is persisted for replay. The getter reads the log lazily — + # it is created when the agent's run loop starts, after this line. + self.permission_handler = _persisting_permission_handler( + self.sink, lambda: getattr(self.agent, "session_log", None) + ) + self.agent = build_agent( + project, + session, + event_sink=self.sink, + input_source=self.input_source, + mcp_config=mcp_config, + permission_handler=self.permission_handler, + ) + # Last user-driven activity (monotonic). The idle sweeper evicts + # runtimes that sat untouched past the TTL — an idle vector project + # otherwise pins its embedded qdrant lock and its in-RAM vectors until + # the server restarts. + self.last_active: float = time.monotonic() + self.run_task: asyncio.Task = asyncio.create_task(self._drive()) + + def touch(self) -> None: + self.last_active = time.monotonic() + + async def _drive(self) -> None: + try: + gen = await self.agent.run(None, stream=True) + async for _ in gen: + pass + except (EOFError, asyncio.CancelledError): + self.sink.push({"type": DRIVER_DONE}) + except Exception as exc: # noqa: BLE001 — surface, don't crash the server + logger.warning("agent loop error", exc_info=True) + self.sink.push({"type": DRIVER_ERROR, "message": f"{type(exc).__name__}: {exc}"}) + else: + self.sink.push({"type": DRIVER_DONE}) + + async def enqueue(self, text: str, marker: dict | None = None) -> None: + self.touch() + # Plain string when no marker, so simple consumers/tests stay unchanged. + await self.input_queue.put((text, marker) if marker else text) + + async def aclose(self) -> None: + if not self.run_task.done(): + self.run_task.cancel() + try: + await self.run_task + except (asyncio.CancelledError, Exception): + pass + try: + await self.agent.cleanup_tools() + except Exception: + logger.debug("agent cleanup skipped", exc_info=True) + + +class RuntimeRegistry: + """In-process registry — the whole chat runtime is single-process state. + + Live agents, their event queues, turn locks and pending permission Futures + all live in this process's memory. Running uvicorn with multiple workers + would scatter requests across processes that cannot see each other's + runtimes (a /api/chat/permission answer landing on the wrong worker can + never resolve the ask). Keep `--workers 1` (uvicorn's default); the + multi-worker upgrade path is sticky session routing or a dedicated + agent-runner process.""" + + # Idle eviction: a runtime untouched this long is torn down, and when it + # was its project's last live runtime the project's shared memory store is + # closed too (releasing the embedded qdrant file lock + resident vectors). + # Env-overridable for ops / tests (seconds and count). Read at USE time, + # not at class definition — this module may be imported before + # app.core.settings has published the .env into os.environ. + @property + def IDLE_TTL_S(self) -> int: + return int(os.environ.get("MSA_RUNTIME_IDLE_TTL", 30 * 60)) + + @property + def SWEEP_INTERVAL_S(self) -> int: + return int(os.environ.get("MSA_RUNTIME_SWEEP_INTERVAL", 60)) + + # Soft cap on simultaneously live runtimes: beyond it the oldest IDLE ones + # are evicted early (in-flight turns are never touched). + @property + def MAX_RUNTIMES(self) -> int: + return int(os.environ.get("MSA_RUNTIME_MAX", 8)) + + def __init__(self) -> None: + self._runtimes: dict[str, SessionRuntime] = {} + self._create_lock = asyncio.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + self._sweeper: asyncio.Task | None = None + + # -- running state ------------------------------------------------------- + + def peek(self, session_id: str) -> "SessionRuntime | None": + """The live runtime, if any — without building one (for attach).""" + rt = self._runtimes.get(session_id) + if rt is not None: + rt.touch() + return rt + + def is_running(self, session_id: str) -> bool: + """Whether the session has a turn in flight (live or background).""" + rt = self._runtimes.get(session_id) + return ( + rt is not None + and rt.turn_lock.locked() + and not rt.run_task.done() + ) + + def running_sessions(self) -> list[str]: + return [sid for sid in list(self._runtimes) if self.is_running(sid)] + + async def get(self, project, session) -> SessionRuntime: + """Return a live runtime for the session, (re)building if the driver has + exited (e.g. after an error) or the active model changed (in-conversation + model switch) so a fresh agent restores from SessionLog with the new model.""" + from app.backends.ms_agent import model_link + + async with self._create_lock: + self._loop = asyncio.get_running_loop() # for cross-thread toggles + self._ensure_sweeper() + rt = self._runtimes.get(session.id) + # Rebuild on model switch, but never mid-turn: an in-flight turn holds + # turn_lock, so defer the swap to the next idle turn to avoid cancelling it. + model_changed = ( + rt is not None + and not rt.turn_lock.locked() + and rt.model_key != model_link.active_model() + ) + if rt is not None and not rt.run_task.done() and not model_changed: + rt.touch() + return rt + if rt is not None: + await rt.aclose() + rt = SessionRuntime(project, session, await self._resolve_mcp(project)) + self._runtimes[session.id] = rt + return rt + + # -- idle eviction ------------------------------------------------------- + + def _ensure_sweeper(self) -> None: + if self._sweeper is None or self._sweeper.done(): + self._sweeper = asyncio.get_running_loop().create_task( + self._sweep_loop()) + + async def _sweep_loop(self) -> None: + while True: + await asyncio.sleep(self.SWEEP_INTERVAL_S) + try: + await self._sweep_once() + except Exception: # noqa: BLE001 - the sweeper must survive + logger.warning("runtime sweep failed", exc_info=True) + + async def _sweep_once(self) -> None: + now = time.monotonic() + idle = [ + rt for rt in list(self._runtimes.values()) + if not rt.turn_lock.locked() # never touch an in-flight turn + ] + expired = { + rt.session.id + for rt in idle if now - rt.last_active > self.IDLE_TTL_S + } + # Over the cap: also evict the oldest idle ones beyond it. + overflow = len(self._runtimes) - self.MAX_RUNTIMES + if overflow > 0: + for rt in sorted(idle, key=lambda r: r.last_active)[:overflow]: + expired.add(rt.session.id) + for sid in expired: + await self._evict(sid) + + async def _evict(self, session_id: str) -> None: + async with self._create_lock: + rt = self._runtimes.get(session_id) + if rt is None or rt.turn_lock.locked(): + return # a turn started while we decided; leave it alone + self._runtimes.pop(session_id, None) + logger.info("evicting idle runtime for session %s", session_id) + await rt.aclose() # cancels the driver; cleanup flushes pending ingest + await self._release_project_memory(rt.project) + + async def _release_project_memory(self, project) -> None: + """Close the project's shared memory store once its LAST runtime is + gone — that is what actually releases the embedded qdrant file lock + (per-agent cleanup deliberately never closes shared instances).""" + if project is None: # partial runtimes (tests) have no project + return + pid = getattr(project, "id", None) + if pid is not None and any( + getattr(rt.project, "id", None) == pid + for rt in self._runtimes.values()): + return # a sibling session still needs the store + try: + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + close = getattr(SharedMemoryManager, "close_matching", None) + if close is None: # older SDK without the helper + return + closed = await close(str(memory_dir(project.path))) + if closed: + logger.info("released shared memory for project %s", pid) + except Exception: # noqa: BLE001 - eviction is best-effort + logger.warning("shared memory release failed for %s", pid, + exc_info=True) + + async def _resolve_mcp(self, project) -> dict: + """Resolve enabled MCP servers and probe them (connect+initialize), so an + unreachable/invalid server is dropped before it can break the chat turn.""" + from ms_agent.tui.managed_config import resolve_mcp_config + + from app.backends.ms_agent import mcp_health + from app.backends.ms_agent.common import home + + raw = (resolve_mcp_config(home(), project.path, None) or {}).get("mcpServers", {}) + healthy = await mcp_health.filter_healthy(raw) + return {"mcpServers": healthy} if healthy else {} + + async def _apply_mcp_toggle(self, name: str, enabled: bool) -> None: + for rt in list(self._runtimes.values()): + mcp_rt = getattr(rt.agent, "mcp_runtime", None) + if mcp_rt is None or mcp_rt.get_server(name) is None: + continue + try: + if enabled: + await mcp_rt.enable_server(name) + else: + await mcp_rt.disable_server(name) + except Exception: + logger.warning("live MCP toggle failed: %s", name, exc_info=True) + + def toggle_mcp(self, name: str, enabled: bool) -> None: + """Sync entry (for sync management routes running in the threadpool): + connect/disconnect a server on any live session that manages it. + Best-effort and non-fatal — persistence is the source of truth.""" + loop = self._loop + if loop is None or not self._runtimes: + return # no live session to affect; change applies on next build + try: + future = asyncio.run_coroutine_threadsafe( + self._apply_mcp_toggle(name, enabled), loop + ) + future.result(timeout=15) + except Exception: + logger.warning("scheduling live MCP toggle failed: %s", name, exc_info=True) + + def resolve_permission(self, session_id: str, request_id: str, action: str) -> bool: + """Answer a pending restricted-mode ask on the session's live runtime. + + Returns False when there is no live runtime or the request is unknown / + already resolved (e.g. it timed out to deny).""" + rt = self._runtimes.get(session_id) + handler = getattr(rt, "permission_handler", None) if rt else None + if handler is None: + return False + future = handler._pending.get(request_id) + if future is None or future.done(): + return False + from ms_agent.permission.handler import PermissionAction, PermissionResponse + + try: + handler.resolve(request_id, PermissionResponse(action=PermissionAction(action))) + except ValueError: + return False + return True + + def set_project_permission_mode(self, project_id: str, mode: str) -> int: + """Hot-apply a project's permission mode to its LIVE runtimes. + + The SDK's ``set_permission_mode`` swaps the enforcer's frozen config in + place, so the next tool call obeys the new mode without rebuilding the + agent (an in-flight turn is unaffected until its next call). Runtimes + built later pick the mode up from the project sidecar at build time. + Returns how many runtimes were updated. + """ + n = 0 + for rt in self._runtimes.values(): + if getattr(rt.project, "id", None) != project_id: + continue + agent = getattr(rt, "agent", None) + if agent is None or not hasattr(agent, "set_permission_mode"): + continue + try: + agent.set_permission_mode(mode) + n += 1 + except Exception: # never let a mode toggle break a live session + logger.debug("permission mode hot-apply skipped", exc_info=True) + return n + + async def interrupt(self, session_id: str) -> bool: + """Explicit stop (POST /api/chat/interrupt): discard the live runtime so + the in-flight SDK generation is cancelled now, then seal the log so the + rebuilt agent answers the NEXT message, not the interrupted one. Returns + False when there is no live runtime for the session. + + This is the *only* path that cancels a turn. A plain client disconnect + (navigating away) does not come here — it drains in the background and + the conversation keeps running (see chat._drain_abandoned_turn).""" + from app.backends.ms_agent.chat import _seal_interrupted_turn + + # Hold _create_lock across the WHOLE stop (pop + cancel + seal), not just + # the pop. Otherwise a next-message get() for this same session could + # build a SECOND runtime on the same SessionLog while we are still + # sealing — the new runtime then clobbers the interrupted turn's history + # (reproduced: the whole interrupted turn vanished from the log). With + # the lock held, that get() waits and rebuilds from the sealed log. + async with self._create_lock: + rt = self._runtimes.pop(session_id, None) + if rt is None: + return False + await rt.aclose() # cancels the driver (SDK interrupt closes upstream) + try: + _seal_interrupted_turn(rt) + except Exception: # never let sealing crash the stop + logger.debug("seal on interrupt skipped", exc_info=True) + # Every removal path must consider releasing the project's shared + # memory — a runtime popped here never reaches the idle sweeper, and + # without this the store's qdrant lock outlives its last runtime. + await self._release_project_memory(getattr(rt, "project", None)) + return True + + async def close(self, session_id: str) -> None: + async with self._create_lock: + rt = self._runtimes.pop(session_id, None) + if rt is not None: + await rt.aclose() + await self._release_project_memory(getattr(rt, "project", None)) + + def discard(self, session_id: str) -> None: + """Sync best-effort stop (for use from sync routes, e.g. session delete): + schedule the driver close on its owning event loop.""" + loop = self._loop + if loop is not None and loop.is_running(): + try: + if asyncio.get_running_loop() is loop: + loop.create_task(self.close(session_id)) + return + except RuntimeError: + pass + try: + future = asyncio.run_coroutine_threadsafe(self.close(session_id), loop) + future.result(timeout=15) + return + except Exception: + logger.warning("scheduling runtime discard failed: %s", session_id, exc_info=True) + + rt = self._runtimes.pop(session_id, None) + if rt is not None and not rt.run_task.done(): + try: + rt.run_task.cancel() + except RuntimeError: + logger.debug("runtime discard skipped for stopped loop", exc_info=True) + + async def close_all(self) -> None: + for sid in list(self._runtimes): + await self.close(sid) + + +registry = RuntimeRegistry() diff --git a/webui/backend/app/backends/ms_agent/sessions.py b/webui/backend/app/backends/ms_agent/sessions.py new file mode 100644 index 000000000..522508f4c --- /dev/null +++ b/webui/backend/app/backends/ms_agent/sessions.py @@ -0,0 +1,1001 @@ +"""Sessions adapter — SessionManager (+ cross-project find) + sidecar preview.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + +from app.backends.errors import NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import ( + autoname_session, + find_session, + pm, + resolve_project, + sm_for, +) +from app.backends.ms_agent.mapping import session_to_schema +from app.core.filetypes import guess_type +from app.schemas.session import ( + Artifact, + Session, + SessionCreate, + SessionFile, + SessionMessage, + SessionPart, + SessionPlan, + SessionStep, + SessionTask, + SessionUpdate, +) + + +def list_sessions(project_id: str | None = None) -> list[Session]: + manager = pm() + if project_id: + proj = manager.get(project_id) + projects = [proj] if proj is not None else [] + else: + projects = manager.list() + + out: list[Session] = [] + for proj in projects: + for s in sm_for(proj).list(): + # backfill a display title for still-default sessions that have history + out.append(autoname_session(proj, s)) + out.sort(key=lambda s: s.updated_at, reverse=True) + return [_with_running(session_to_schema(s)) for s in out] + + +def _with_running(schema: Session) -> Session: + """Stamp the live turn-in-flight flag (registry state; lazy import keeps + mapping.py free of a runtime dependency).""" + from app.backends.ms_agent.runtime import registry + + schema.running = registry.is_running(schema.id) + return schema + + +def create_session(body: SessionCreate) -> Session: + try: + project = resolve_project(body.project_id) + except KeyError: + project = resolve_project( + None) # unknown id -> default (mock is lenient) + session = sm_for(project).create(name=body.title) + if body.preview: + sidecar.merge("sessions", session.id, {"preview": body.preview}) + return session_to_schema(session) + + +def get_session(sid: str) -> Session: + found = find_session(sid) + if not found: + raise NotFound("session not found") + _project, session, _sm = found + return _with_running(session_to_schema(session)) + + +def _history_step( + tc: dict, + errored: dict[str, str], + results: dict[str, str], + durations: dict[str, int], + project=None, +) -> SessionStep | None: + """Map one persisted assistant tool_call to a step card, or None to drop it. + + Reuses the live event mapping so history renders the same step cards. The + log shape differs from the live event: the tool name is under ``tool_name`` + (or a nested ``function.name``) and ``arguments`` is a raw JSON string. The + tool result is looked up by call id in ``results`` (and ``errored`` for a + failed call), so the detail drawer shows the full invocation like a live + step; ``durations`` supplies the persisted elapsed time (``duration_ms``); + a failed call is marked ``status="error"``. + + For ``file_read``/``file_write``/``file_edit`` cards, ``meta["exists"]`` + records whether the referenced workspace file is still present so the + frontend can open it (or render a non-clickable "deleted" card when it's + gone). + """ + from app.backends.ms_agent.chat import _as_dict, _tool_step_meta + + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = tc.get("tool_name") or fn.get("name") or "" + args = _as_dict(tc.get("arguments", fn.get("arguments"))) + meta = _tool_step_meta(str(name), args) + if meta is None: + return None + kind = str(meta.pop("kind")) + # Full invocation for the detail drawer (mirrors the live _tool_step). + meta["tool"] = str(name) + meta["arguments"] = args + call_id = str(tc.get("id") or "") + if call_id and call_id in results: + meta["result"] = results[call_id] + if call_id and call_id in durations: + meta["duration_ms"] = durations[call_id] + if call_id and call_id in errored: + meta["status"] = "error" + meta["error"] = errored[call_id] + # A DENIED call is already rendered by its persisted permission record + # (the rejected authorization card) — drop this redundant tool step so + # history doesn't show two identical "rejected" cards. + if "denied" in str(errored[call_id]).lower(): + return None + # NOTE: an errored/interrupted file step used to be re-kinded to `tool_call` + # here, to get the rich accordion (arguments + error) instead of the + # simplified "wrote x" one-liner. The file card renders that accordion itself + # now — keeping the file's glyph and "write file {path}" title — so re-kinding + # only threw the identity away and replayed "call tool file_system---write_file" + # where the live stream shows the file. + if kind in ("file_read", "file_write", + "file_edit") and project is not None: + # Multi-file steps carry `paths: [...]`; the display `path` is a + # comma-joined string that would never resolve. Check each real file + # and mark existing only when ALL are present (single-file steps just + # check their one `path`). + multi = meta.get("paths") + try: + if isinstance(multi, list) and multi: + meta["exists"] = all( + (Path(project.path) / str(p)).is_file() for p in multi + ) + else: + path = str(meta.get("path") or "") + if path: + meta["exists"] = (Path(project.path) / path).is_file() + except OSError: + meta["exists"] = False + return SessionStep(kind=kind, meta=meta) + + +def _permission_step(row: dict) -> SessionStep | None: + """Build a replayed (read-only) authorization card from a persisted + permission record, or None to drop it. Mirrors the live + ``chat._permission_step`` meta shape, minus the live-only + request_id/session_id (a replayed card is resolved, so it renders its + ``state`` and shows no buttons). + + Asks folded into their tool's own card while live (``_AUTH_INLINE_KINDS``, + e.g. a shell command) replay the same way, so a rejected command still shows + its terminal code block. An APPROVED one is dropped: its tool step replayed + right after says the same thing (that is what the live stream ends up + showing too, the result card having replaced the ask in place). + """ + tool = str(row.get("tool_name") or "") + args = row.get("arguments") if isinstance(row.get("arguments"), + dict) else {} + preview = json.dumps(args, ensure_ascii=False) + if len(preview) > 160: + preview = preview[:160] + "…" + from app.backends.ms_agent.chat import _inline_auth_meta, _tool_source + + state = str(row.get("state") or "approved") + meta = _inline_auth_meta( + { + "kind": "authorization", + "state": state, + "tool_name": tool, + "arguments": args, + "desc": f"{tool} {preview}".strip(), + "source": _tool_source(tool), + }, tool, args) + kind = str(meta.pop("kind")) + if kind != "authorization" and state == "approved": + return None + return SessionStep(kind=kind, meta=meta) + + +def _plan_entries(tc: dict, results: dict[str, str]) -> list | None: + """Extract the plan (todo) items from a persisted ``todo_list---todo_write`` + call, or None if this call isn't a plan write. + + Prefers the tool RESULT's ``todos`` (the authoritative full plan after the + tool merges status updates), falling back to the call arguments. This lets + history rebuild the plan the same way the live ``plan_updated`` event does. + """ + from app.backends.ms_agent.chat import _as_dict + + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + base, _, leaf = name.partition("---") + if base != "todo_list" or leaf != "todo_write": + return None + call_id = str(tc.get("id") or "") + if call_id in results: + try: + data = json.loads(results[call_id]) + except (ValueError, TypeError): + data = None + if isinstance(data, dict) and isinstance(data.get("todos"), list): + return data["todos"] + todos = _as_dict(tc.get("arguments", fn.get("arguments"))).get("todos") + return todos if isinstance(todos, list) else None + + +def _plan_part(entries: list) -> SessionPart: + """Build the single ``tasks`` plan part from todo entries, mapping each + todo status to the frontend task status (mirrors chat.py::_tasks).""" + from app.backends.ms_agent.chat import _PLAN_STATUS + + tasks: list[SessionTask] = [] + for i, entry in enumerate(entries): + entry = entry if isinstance(entry, dict) else {"content": str(entry)} + tasks.append( + SessionTask( + id=str(i), + label=str(entry.get("content", "")), + status=_PLAN_STATUS.get(str(entry.get("status", "pending")), + "pending"), + )) + return SessionPart(kind="tasks", tasks=tasks) + + +def _disk_plan(plan_path: str) -> list | None: + """The todos in a ``plan.json`` — the agent's live plan file, which also + captures any manual edits — or None if absent/unparseable.""" + try: + with open(plan_path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None + todos = data.get("todos") if isinstance(data, dict) else None + return todos if isinstance(todos, list) else None + + +def read_plan(session_id: str) -> SessionPlan: + """The pinned composer plan box reflects the session's live ``plan.json`` + — the plan file written by the todo_list tool and by any manual edits. It + deliberately ignores the chat/session log. + + Plans are SESSION-scoped: ``build_agent`` points the todo tool at + ``/plan.json`` so concurrent sessions in one project no longer + clobber each other's plans. Sessions created before that change fall back + to the legacy project-shared ``/plan.json``. Empty when + neither exists or the file can't be parsed. + + ``active`` = a turn is in flight AND the plan file was written during it + (mtime vs the runtime's wall-clock turn origin) — the server-side truth + the frontend uses to animate "running" rows, stable across reloads and + tab switch-backs.""" + found = find_session(session_id) + if not found: + return SessionPlan() + project, session, _ = found + from app.backends.ms_agent.config import session_dir + + plan_path = os.path.join(session_dir(project, session), "plan.json") + todos = _disk_plan(plan_path) + if todos is None: # pre-isolation sessions used a project-shared plan + plan_path = os.path.join(project.path, "plan.json") + todos = _disk_plan(plan_path) + if todos is None: + return SessionPlan() + + active = False + try: + from app.backends.ms_agent.runtime import registry + + if registry.is_running(session_id): + rt = registry.peek(session_id) + started_wall = getattr(rt, "turn_started_wall", None) + if started_wall is not None: + # 1s slack: the tool may write the file in the same tick the + # origin is stamped. + active = os.path.getmtime(plan_path) >= started_wall - 1.0 + except Exception: + active = False + return SessionPlan(tasks=_plan_part(todos).tasks, active=active) + + +def list_messages(sid: str) -> list[SessionMessage]: + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, sm = found + rows: list[dict] = [] + extra: list[dict] = [] + try: + log = sm.get_session_log(session) + rows = log.get_all_messages() + # Display-only records (excluded from the LLM context) are read + # separately and merged back into the timeline by seq: error records + # (API/turn errors) and permission records (restricted-mode auth cards). + if hasattr(log, "get_errors"): + extra += log.get_errors() + if hasattr(log, "get_permissions"): + extra += log.get_permissions() + if hasattr(log, "get_skill_invocations"): + extra += log.get_skill_invocations() + if hasattr(log, "get_loop_ends"): + extra += log.get_loop_ends() + except Exception: + rows, extra = [], [] + stream = sorted([*rows, *extra], key=lambda r: r.get("seq", 0)) + return _reconstruct(stream, project) + + +# Marker prefixed to the enqueued prompt by chat._compose_prompt when the user +# attached files. Reconstruction splits it back out so replay shows file cards +# instead of the raw path list. +_ATTACHED_MARKER = "[Attached files]" + + +def _split_attached(content: str) -> tuple[str, list[str]]: + """Split a persisted user message into (display_text, attached_paths). + + The attachment block (see chat._compose_prompt) lists ``- `` lines + after the marker. Everything before the marker is the user's typed text. + """ + idx = content.find(_ATTACHED_MARKER) + if idx == -1: + return content, [] + text = content[:idx].strip() + paths: list[str] = [] + for line in content[idx:].splitlines(): + line = line.strip() + if line.startswith("- "): + p = line[2:].strip() + if p: + paths.append(p) + return text, paths + + +def _file_kind(name: str) -> str: + ct = guess_type(name) or "" + if ct.startswith("image/"): + return "image" + if ct.startswith("audio/"): + return "audio" + if ct.startswith("video/"): + return "video" + return "file" + + +def _raw_url(pid: str, path: str) -> str: + enc = "/".join(quote(seg) for seg in path.split("/")) + return f"/api/projects/{quote(pid)}/workspace/files/{enc}/raw" + + +def _attached_files(project, paths: list[str]) -> list[SessionFile]: + root = Path(project.path) + out: list[SessionFile] = [] + for p in paths: + size: int | None = None + try: + fp = root / p + exists = fp.is_file() + if exists: + size = fp.stat().st_size + except OSError: + exists = False + out.append( + SessionFile( + name=p.split("/")[-1], + path=p, + url=_raw_url(project.id, p), + type=_file_kind(p), + size=size, + exists=exists, + )) + return out + + +def _reconstruct(rows: list[dict], project=None) -> list[SessionMessage]: + """Rebuild ordered display messages from the append-only log. + + One logical assistant turn spans several rows (tool-call rows carrying + placeholder text + a final answer row + interleaved tool results). Collapse + them into a single assistant bubble whose ``parts`` preserve the real order + of answer text and the tool/step timeline: an assistant row's persisted + ``reasoning_content`` becomes a ``thought`` part (in row order, before that + row's answer/steps); answer-text rows become ``text`` parts; each tool_call + row becomes its own linear ``step`` part in stream order (no task nesting). + A failed tool result (``role="tool"`` + ``is_error``) marks its step + ``status="error"``. A ``_type="error"`` record (API/turn error, excluded + from model context) becomes its own ``error`` message. Rows re-appended by + context compaction (``_source="compaction"`` — the squeezed LLM view, incl. + the synthetic summary row) are skipped: history shows the original + timeline. ``system`` and plain ``tool`` rows are otherwise dropped. + """ + from app.backends.ms_agent.chat import is_placeholder_content + + # Failed tool results keyed by call id, so the matching tool_call step can be + # marked errored (the assistant tool_call row precedes its tool result row). + errored: dict[str, str] = { + str(r.get("tool_call_id") or ""): str(r.get("content") or "") + for r in rows if r.get("role") == "tool" and r.get("is_error") + } + # All tool results by call id, so a step can show its full result content. + results: dict[str, str] = { + str(r.get("tool_call_id") or ""): str(r.get("content") or "") + for r in rows if r.get("role") == "tool" + } + # Persisted tool elapsed time by call id (display only), for the step card. + durations: dict[str, int] = { + str(r.get("tool_call_id") or ""): int(r.get("duration_ms")) + for r in rows if r.get("role") == "tool" + and isinstance(r.get("duration_ms"), (int, float)) + } + + # Workspace root + the session's plan-file locations (reported by the todo + # tool in these rows), for classifying file writes into the changed-files + # summary: out-of-workspace writes and plan files are not deliverables. + ws = _ws_root(project) + plan_paths = plan_paths_in_rows(rows, ws) if ws else None + + messages: list[SessionMessage] = [] + parts: list[SessionPart] = [] + # Tool-round group id: one assistant row's tool_calls array = one round + # (mirrors the live mapper's `group` stamping on step metas). + group_seq = 0 + # Skill-invocation marker of a slash/structured skill turn: carries the + # user's ORIGINAL text + picked skill ids, shown in place of the expanded + # prompt on the user row that follows (and echoed as segments). + pending_skill: dict | None = None + # Restricted-mode authorization records awaiting their matching tool_call + # step. They persist EAGERLY at ask() time (mid-round), so their seq + # predates the assistant reasoning/tool_call rows persisted at the round + # boundary — replaying them at their seq position would wrongly place the + # auth card before the turn's thought, splitting it from its tool step. We + # instead buffer them and insert each card immediately before the step it + # authorized (matched by tool + arguments), so replay order matches live + # (thought → auth → tool step, adjacent). Unmatched ones flush at turn end. + pending_perms: list[dict] = [] + # Workspace paths written/edited in the current turn's tool-call loop, for + # the assistant message's changed-files summary (frontend collapse). + turn_changed: list[str] = [] + turn_changed_seen: set[str] = set() + # Wall-clock loop duration from this turn's persisted loop_end marker (the + # one field not derivable from message rows). None until the marker is seen. + turn_duration_ms: int | None = None + # Absolute path of the session plan markdown (loop_end marker's + # ``plan_file``), set when the turn rewrote the todo list. + turn_plan_file: str | None = None + + def _perm_key(tool: str, args) -> str: + # Normalize dict or JSON-string arguments to a canonical form so a + # permission record matches its tool_call regardless of shape. + if isinstance(args, str): + try: + args = json.loads(args) + except (ValueError, TypeError): + args = {} + if not isinstance(args, dict): + args = {} + return tool + "\x1f" + json.dumps( + args, sort_keys=True, ensure_ascii=False) + + def _take_matching_perm(tool: str, args, call_id: str = "") -> dict | None: + # Prefer an exact tool_call-id match (unambiguous even when a round + # fires several identical calls in parallel); fall back to + # (tool, args) FIFO for records that predate call_id (old logs) or when + # the adapter hadn't assigned an id at ask time. + if call_id: + for i, rec in enumerate(pending_perms): + if str(rec.get("call_id") or "") == call_id: + return pending_perms.pop(i) + # Fallback: (tool, args) FIFO over whatever remains — covers old logs, + # empty ids, and id-bearing records whose call had no id at match time. + key = _perm_key(tool, args) + for i, rec in enumerate(pending_perms): + if _perm_key(str(rec.get("tool_name") or ""), + rec.get("arguments")) == key: + return pending_perms.pop(i) + return None + + def flush() -> None: + nonlocal parts, pending_perms, turn_changed, turn_changed_seen + nonlocal turn_duration_ms, turn_plan_file + # Any authorization that never matched a tool step (unusual) still + # renders, appended in record order so nothing is dropped. + for rec in pending_perms: + pstep = _permission_step(rec) + if pstep is not None: + parts.append(SessionPart(kind="step", step=pstep)) + pending_perms = [] + content = "\n\n".join(p.text for p in parts + if p.kind == "text" and p.text).strip() + if content or any(p.kind in ("step", "thought", "tasks", "interrupted", + "error") for p in parts): + messages.append( + SessionMessage(role="assistant", + content=content, + parts=parts, + changed_files=turn_changed, + duration_ms=turn_duration_ms, + plan_file=turn_plan_file)) + parts = [] + turn_changed = [] + turn_changed_seen = set() + turn_duration_ms = None + turn_plan_file = None + + def append_text(text: str) -> None: + # Merge consecutive answer rows into the current text block; start a new + # block if a step was emitted in between (preserving stream order). + if parts and parts[-1].kind == "text": + prev = parts[-1].text + parts[-1].text = f"{prev}\n\n{text}" if prev else text + else: + parts.append(SessionPart(kind="text", text=text)) + + for row in rows: + if row.get("_source") == "compaction": + # Compacted-view re-appends duplicate earlier rows for the LLM + # window only; replaying them would double the timeline. + continue + if row.get("_type") == "loop_end": + # Persisted loop boundary: carries the wall-clock duration for this + # turn (its seq lands after the turn's rows, before the next user + # row, so it applies to the turn being accumulated). changed_files + # is still derived below (robust); duration and the plan-file + # location are taken from the marker. + d = row.get("duration_ms") + if isinstance(d, (int, float)): + turn_duration_ms = int(d) + pf = row.get("plan_file") + if isinstance(pf, str) and pf: + turn_plan_file = pf + continue + if row.get("_type") == "error": + # Accumulate into the CURRENT turn instead of flushing it early. + # Flushing here emitted the error as its own message and closed the + # turn before the `loop_end` marker (which lands at a later seq) had + # been read — so the turn's duration was applied to an already-empty + # part list and dropped, and replay showed no "N s" where the live + # view had one. Accumulating also matches the live stream, where an + # error frame is just another part of the running message. + parts.append( + SessionPart( + kind="error", + text=str(row.get("message") or ""), + recoverable=bool(row.get("recoverable", False)), + )) + continue + if row.get("_type") == "permission": + # Buffer until its matching tool_call step is emitted (see + # pending_perms above); keeps the auth card adjacent to the tool it + # authorized, matching the live frame order. + pending_perms.append(row) + continue + if row.get("_type") == "skill_invocation": + # A slash-skill turn persists the EXPANDED prompt as the user row + # (that is what the model must see); this marker precedes it and + # carries what the user actually typed + the picked skill ids + # (and, for configuration-style turns, the segments as sent). + pending_skill = { + "original_text": + str(row.get("original_text") or ""), + "skill_ids": + [str(s) for s in (row.get("skill_ids") or []) if s], + "segments": [ + s for s in (row.get("segments") or []) + if isinstance(s, dict) + ], + } + continue + role = row.get("role") + if role == "user": + flush() + content = row.get("content") + if content is not None: + display = (pending_skill + or {}).get("original_text") or str(content) + skill_ids = (pending_skill or {}).get("skill_ids") or [] + sent_segments = (pending_skill or {}).get("segments") or [] + pending_skill = None + text, paths = _split_attached(display) + files = (_attached_files(project, paths) + if paths and project is not None else []) + # Configuration-style echo: prefer the segments AS SENT by the + # composer (skill id + display name + text); fall back to + # rebuilding from bare skill ids for older records. + if sent_segments: + segments = sent_segments + elif skill_ids: + segments = [{ + "type": "skill", + "id": sid + } for sid in skill_ids] + ([{ + "type": "text", + "text": text.strip() + }] if text.strip() else []) + else: + segments = [] + messages.append( + SessionMessage( + role="user", + content=text, + files=files, + segments=segments, + )) + elif role == "assistant": + # An interrupted round's unsigned partial reasoning is persisted + # under a display-only key (replaying it would 400 on Anthropic); + # it renders as a normal finished thought block. + reasoning = row.get("reasoning_content") or row.get( + "interrupted_reasoning") + if reasoning: + # One persisted reasoning block per assistant row, placed before + # that row's answer text / tool steps (stream order); its elapsed + # time replays as "thought Ns". + dur = row.get("reasoning_duration") + parts.append( + SessionPart( + kind="thought", + text=str(reasoning), + duration=int(dur) if isinstance(dur, + (int, + float)) else None, + )) + tool_calls = row.get("tool_calls") + if tool_calls: + # This row's content is the model's mid-turn narration: the + # live stream showed it, so replay does too — verbatim. Only an + # empty string (→ empty text part) or framework filler is + # skipped (same rule as the no-tool-call branch below, so a + # ``content_placeholder`` row is honored whatever its shape). + narration = str(row.get("content") or "") + if narration and not is_placeholder_content(row): + append_text(narration) + # SERVER-SIDE grouping truth: this assistant row's tool_calls + # array IS one tool round — every step it yields shares one + # `group` id, so the frontend nests them under one accordion + # (matching the live mapper's group stamping). + group_seq += 1 + # Intermediate step; its content is a placeholder, not an answer. + for tc in tool_calls: + if not isinstance(tc, dict): + continue + # Accumulate this loop's written/edited files (plus + # "plan.md" for todo writes) for the turn's changed-files + # summary. + wpath = _changed_entry(tc, ws, plan_paths) + if wpath and wpath not in turn_changed_seen: + turn_changed_seen.add(wpath) + turn_changed.append(wpath) + # A todo_write is plan machinery: append a plan SNAPSHOT + # at this point in the timeline (never a step card, and + # never refreshed in place) — mirrors the live stream, where + # every plan update adds a new frozen block. The composer's + # pinned panel is the live/aggregated view. + entries = _plan_entries(tc, results) + if entries is not None: + parts.append(_plan_part(entries)) + continue + # Any other tool call becomes its own linear step part + # (``durations`` supplies the persisted tool elapsed time). + # Consume the matching restricted-mode ask FIRST (before the + # step-None check): a DENIED call drops its step, but the + # rejected auth card must still render at the call's original + # position (not flushed to the turn end). Pair by call_id + # (exact even for parallel identical calls), falling back to + # (tool, args) FIFO for old logs / empty ids. + tool_name = str( + tc.get("tool_name") + or (tc.get("function") or {}).get("name") or "") + perm = _take_matching_perm(tool_name, + tc.get("arguments"), + call_id=str(tc.get("id") or "")) + if perm is not None: + pstep = _permission_step(perm) + if pstep is not None: + pstep.meta["group"] = group_seq + parts.append( + SessionPart(kind="step", step=pstep)) + step = _history_step(tc, errored, results, durations, + project) + if step is not None: + step.meta["group"] = group_seq + parts.append(SessionPart(kind="step", step=step)) + else: + content = row.get("content") + # A synthetic filler content (the interrupt seal's neutral + # placeholder) only exists to close the turn for the model; the + # UI shows the interrupted badge (below) instead of the literal. + if content and not is_placeholder_content(row): + append_text(str(content)) + if row.get("interrupted"): + # Faithful-interrupt marker: the row carries its partial content + # verbatim (rendered above); the badge marks the exact stop + # point so replay matches what the live view showed. + parts.append(SessionPart(kind="interrupted")) + # system / plain tool rows are not rendered (tool errors handled above). + + flush() + return messages + + +def delete_session(sid: str) -> None: + from app.backends.ms_agent.runtime import registry + + found = find_session(sid) + if not found: + raise NotFound("session not found") + _project, _session, sm = found + registry.discard(sid) # stop any live agent before removing its log + sm.delete(sid) + sidecar.drop("sessions", sid) + + +def update_session(sid: str, body: SessionUpdate) -> Session: + """Rename a session (update its title).""" + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, _sm = found + if body.title is not None: + sm_for(project).update(sid, name=body.title) + session = sm_for(project).get(sid) + return session_to_schema(session) + + +def _artifact_id(path: str) -> str: + return "art_" + hashlib.sha1(path.encode("utf-8")).hexdigest()[:12] + + +def _ws_root(project) -> str | None: + """The project workspace root — the working directory file-tool relative + paths resolve against (``project.path``; mounted dir for mounted projects, + the internal project dir otherwise).""" + try: + p = str(getattr(project, "path", "") or "") + return os.path.normpath(p) if p else None + except Exception: + return None + + +def _resolve_tool_path(workspace: str, path: str) -> str: + """Absolute, normalized location of a file-tool path argument (relative + paths join the workspace root — the tools' working directory).""" + p = path if os.path.isabs(path) else os.path.join(workspace, path) + return os.path.normpath(p) + + +def _workspace_rel(workspace: str, abspath: str) -> str | None: + """``abspath`` as a workspace-relative path, or None when it lies OUTSIDE + the workspace (session dir, ``..`` escapes, absolute paths elsewhere) — + such writes are session/system state, not workspace deliverables.""" + root = os.path.normpath(workspace) + if not abspath.startswith(root + os.sep): + return None + return os.path.relpath(abspath, root) + + +_RENDERED_MD_RE = re.compile(r"^OK: rendered plan markdown to (.+)$") + + +def plan_paths_in_rows(rows: list[dict], workspace: str) -> set[str]: + """Absolute locations of this session's PLAN files, as reported by the + todo tool itself — no filename heuristics: a plan named ``xxx_plan.md`` + (or anything else) is recognized because the tool call said so, not + because of how it is named. Sources: + + - ``todo_write`` results carry ``plan_path`` (the plan json, relative to + the tool's output dir); its auto-rendered same-stem ``.md`` twin counts + too. + - ``todo_render_md`` results name the markdown file they produced (the + model may point it anywhere, e.g. into the workspace under any name). + - persisted ``loop_end`` markers carry the resolved ``plan_file``. + + Used to keep plan files out of the file ledgers (changed_files summary, + session artifacts → the composer's file list).""" + out: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + pf = row.get("plan_file") + if row.get("_type") == "loop_end" and isinstance(pf, str) and pf: + out.add(os.path.normpath(pf)) + continue + if row.get("role") != "tool": + continue + content = str(row.get("content") or "").strip() + m = _RENDERED_MD_RE.match(content) + if m: + out.add(_resolve_tool_path(workspace, m.group(1).strip())) + continue + if '"plan_path"' not in content: + continue + try: + data = json.loads(content) + except (ValueError, TypeError): + continue + pp = data.get("plan_path") if isinstance(data, dict) else None + if isinstance(pp, str) and pp: + ap = _resolve_tool_path(workspace, pp) + out.add(ap) + stem, ext = os.path.splitext(ap) + if ext == ".json": + out.add(stem + ".md") + return out + + +def latest_rendered_plan_md(rows: list[dict], workspace: str) -> str | None: + """Absolute path of the LAST markdown the todo tool rendered in ``rows`` + (a ``todo_render_md`` result), or None. The most recently created/updated + plan artifact is the one the plan chip should point at.""" + latest: str | None = None + for row in rows: + if not isinstance(row, dict) or row.get("role") != "tool": + continue + m = _RENDERED_MD_RE.match(str(row.get("content") or "").strip()) + if m: + latest = _resolve_tool_path(workspace, m.group(1).strip()) + return latest + + +def _written_path(tc: dict) -> str | None: + """The workspace path a ``file_system---write_file/edit_file`` tool_call + targets, or None if this call isn't a file write/edit. Shared by the + artifact ledger and the per-loop changed-files summary.""" + from app.backends.ms_agent.chat import _PATH_KEYS, _as_dict + + if not isinstance(tc, dict): + return None + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + base, _, leaf = name.partition("---") + if base != "file_system" or leaf not in ("write_file", "edit_file"): + return None + args = _as_dict(tc.get("arguments", fn.get("arguments"))) + return next( + (args[k] + for k in _PATH_KEYS if isinstance(args.get(k), str) and args[k]), + None, + ) + + +def _is_plan_write(tc: dict) -> bool: + """True for a todo tool call that created/updated the session plan files + (``todo_write`` rewrites them; ``todo_render_md`` renders the markdown), + so the loop's changed-files summary carries the reserved ``plan.md`` + marker (the plan is session state, not a workspace file — its content is + served by ``GET /sessions/{id}/plan``).""" + if not isinstance(tc, dict): + return False + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + return name in ("todo_list---todo_write", "todo_list---todo_render_md") + + +def _changed_entry( + tc: dict, + workspace: str | None = None, + plan_paths: set[str] | None = None, +) -> str | None: + """This tool_call's contribution to the changed-files summary: the + workspace-relative path of a file write/edit, the reserved ``"plan.md"`` + marker for a plan write, else None. With a ``workspace`` root, file + writes that resolve OUTSIDE it (e.g. the model copying its plan into the + session dir via ``..`` or an absolute path) or onto a known plan file + (``plan_paths``) are excluded — they are plan/session state, not + workspace deliverables.""" + if _is_plan_write(tc): + return "plan.md" + path = _written_path(tc) + if not path: + return None + if workspace is None: + return path + ap = _resolve_tool_path(workspace, path) + if plan_paths and ap in plan_paths: + return None + return _workspace_rel(workspace, ap) + + +def changed_files_in_rows( + rows: list[dict], + workspace: str | None = None, + plan_paths: set[str] | None = None, +) -> list[str]: + """Files changed across the given assistant rows, in first-write order, + deduped: workspace-relative paths written/edited plus the reserved + ``plan.md`` marker when the todo plan was rewritten. With a ``workspace`` + root, out-of-workspace writes and plan files (``plan_paths``, derived + from the rows when omitted) are filtered out. The per-loop equivalent of + the session-wide artifact ledger — used to summarize a completed + tool-call loop (frontend collapses the intermediate steps and shows this + changed-files set).""" + if workspace is not None and plan_paths is None: + plan_paths = plan_paths_in_rows(rows, workspace) + ordered: list[str] = [] + seen: set[str] = set() + for row in rows: + if row.get("role") != "assistant": + continue + for tc in row.get("tool_calls") or []: + path = _changed_entry(tc, workspace, plan_paths) + if path and path not in seen: + seen.add(path) + ordered.append(path) + return ordered + + +def list_artifacts(sid: str) -> list[Artifact]: + """Per-conversation artifact ledger: every workspace file the agent WROTE or + EDITED during this session, in first-write order, deduped by path. + + Derived from the immutable, append-only SessionLog tool-call records (not a + live directory scan), so it reflects what the conversation produced + regardless of what the user later did to those files: a file the agent wrote + but the user then deleted stays listed as ``deleted=True`` (the design's + greyed "deleted" card), and a later user edit does not remove the entry. + + v1 only tracks the ``file_system`` write/edit tools, which carry an explicit + path argument. Files created indirectly by ``code_executor``/shell are not + captured here (no reliable path in the call); a workspace-snapshot diff over + the SDK's ``.ms_agent/snapshots/`` repo is the planned follow-up. + """ + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, sm = found + try: + rows = sm.get_session_log(session).get_all_messages() + except Exception: + rows = [] + + # The ledger only lists WORKSPACE deliverables: writes resolving outside + # the workspace (e.g. the model copying its plan into the session dir via + # ``..`` or an absolute path) and the session's plan files (identified by + # the todo tool's own reports — any configured filename) are excluded, so + # the composer's file list stays plan-free and workspace-scoped. Entries + # are normalized workspace-relative paths, first-write order, deduped. + ws = _ws_root(project) or str(project.path) + plan_paths = plan_paths_in_rows(rows, ws) + # Belt and braces: the session's own plan files at the webui default + # location, in case no todo result made it into the log. + try: + from app.backends.ms_agent.config import session_dir + + sdir = session_dir(project, session) + plan_paths |= { + os.path.normpath(os.path.join(sdir, n)) + for n in ("plan.json", "plan.md") + } + except Exception: + pass + + ordered: list[str] = [] + seen: set[str] = set() + for row in rows: + if row.get("role") != "assistant": + continue + for tc in row.get("tool_calls") or []: + rel = _changed_entry(tc, ws, plan_paths) + if rel and rel != "plan.md" and rel not in seen: + seen.add(rel) + ordered.append(rel) + + out: list[Artifact] = [] + for path in ordered: + abs_path = os.path.join(ws, path) + try: + st = os.stat(abs_path) + size = int(st.st_size) + updated = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + deleted = False + except OSError: + # Written during the turn but gone now — keep it, marked deleted. + size = 0 + updated = datetime.now(tz=timezone.utc) + deleted = True + out.append( + Artifact( + id=_artifact_id(path), + session_id=sid, + path=path, + name=os.path.basename(path.rstrip("/")) or path, + kind="file", + size=size, + updated_at=updated, + deleted=deleted, + )) + return out diff --git a/webui/backend/app/backends/ms_agent/settings_store.py b/webui/backend/app/backends/ms_agent/settings_store.py new file mode 100644 index 000000000..c62246389 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/settings_store.py @@ -0,0 +1,20 @@ +"""Process-local guard for SDK settings.json read-modify-write sequences.""" +from __future__ import annotations + +import threading +from contextlib import contextmanager +from collections.abc import Iterator + +_settings_lock = threading.RLock() + + +@contextmanager +def settings_lock() -> Iterator[None]: + """Serialize settings.json mutations made through SDK manager adapters. + + The SDK managers rewrite the whole settings file. FastAPI sync routes run in + a threadpool, so two management requests can otherwise load the same old + file and save incompatible partial updates. + """ + with _settings_lock: + yield diff --git a/webui/backend/app/backends/ms_agent/sidecar.py b/webui/backend/app/backends/ms_agent/sidecar.py new file mode 100644 index 000000000..9bb980961 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/sidecar.py @@ -0,0 +1,78 @@ +"""WebUI-only field store (``/webui_meta.json``). + +Holds fields the SDK does not model so the frontend keeps working unchanged: +project description / auto-attach toggles, session preview, profile +agent_calls_user, agent-settings auto-attach masters, provider enabled / +generation params, model display/advanced params, and per-project memory items. + +Generic nested store: section -> key -> value. Read-modify-write under a lock +(management routes run in the threadpool).""" +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path + +from app.backends.ms_agent.common import home + +_lock = threading.Lock() + + +def _path() -> Path: + return Path(home()) / "webui_meta.json" + + +def _load() -> dict: + p = _path() + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def _save(data: dict) -> None: + p = _path() + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + + +def get(section: str, key: str, default=None): + with _lock: + return _load().get(section, {}).get(key, default) + + +def section(name: str) -> dict: + with _lock: + return dict(_load().get(name, {})) + + +def put(section: str, key: str, value) -> None: + with _lock: + data = _load() + data.setdefault(section, {})[key] = value + _save(data) + + +def merge(section: str, key: str, patch: dict) -> None: + """Deep-merge a dict patch into section[key] (creating it as {}).""" + with _lock: + data = _load() + current = data.setdefault(section, {}).setdefault(key, {}) + if not isinstance(current, dict): + current = {} + current.update(patch) + data[section][key] = current + _save(data) + + +def drop(section: str, key: str) -> None: + with _lock: + data = _load() + if section in data and key in data[section]: + del data[section][key] + _save(data) diff --git a/webui/backend/app/backends/ms_agent/skill_notice.py b/webui/backend/app/backends/ms_agent/skill_notice.py new file mode 100644 index 000000000..43e8b177c --- /dev/null +++ b/webui/backend/app/backends/ms_agent/skill_notice.py @@ -0,0 +1,182 @@ +"""Skill-update notices — tail-only skill sync for a session's model context. + +The system prompt's skill list is a session-start snapshot and is never +rewritten (``skills.update_notice`` keeps the head byte-stable, so the +provider prefix cache survives skill changes). Instead, when the effective +skill surface changes — add/remove, enable/disable, description edit, or any +file change inside a skill's directory (SKILL.md, references/, scripts/, …) — +the next turn's user message is prefixed with a ```` notice +carrying the FULL current list. The static prompt section tells the model the +latest notice is authoritative. + +The per-session sidecar ``skill_surface.json`` (beside plan.json) records what +the model was last told. It is only committed AFTER the turn is actually +enqueued — a failed/intro-only turn leaves it untouched so the notice re-fires +next time (safe over-notify, never silent-drop). +""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +from datetime import datetime, timezone + +from app.backends.ms_agent.config import session_dir, session_has_history + +logger = logging.getLogger("app.ms_agent.skill_notice") + +_SURFACE_FILE = "skill_surface.json" + + +# -- surface ------------------------------------------------------------------- + + +def _files_sig(skill_path: str) -> str: + """Cheap whole-directory signature: sorted (relpath, mtime_ns, size) over + every non-hidden file under the skill root — SKILL.md, references/, + scripts/, assets all included. Content is never read.""" + h = hashlib.sha256() + try: + for root, dirs, files in os.walk(skill_path): + dirs[:] = sorted(d for d in dirs if not d.startswith(".")) + for name in sorted(files): + if name.startswith("."): + continue + p = os.path.join(root, name) + try: + st = os.stat(p) + except OSError: + continue + rel = os.path.relpath(p, skill_path) + h.update(f"{rel}|{st.st_mtime_ns}|{st.st_size}\n".encode()) + except OSError: + pass + return h.hexdigest()[:16] + + +def build_surface(catalog) -> dict: + """{skill_id: {name, sig, files}} for the ENABLED skills — the exact set + the model is (to be) told about.""" + surface: dict = {} + for sid, skill in (catalog.get_enabled_skills() or {}).items(): + name = getattr(skill, "name", sid) or sid + desc = getattr(skill, "description", "") or "" + surface[sid] = { + "name": name, + "sig": hashlib.sha256( + f"{name}\x1f{desc}".encode()).hexdigest()[:16], + "files": _files_sig(str(getattr(skill, "skill_path", "") or "")), + } + return surface + + +def _surface_path(project, session) -> str: + return os.path.join(session_dir(project, session), _SURFACE_FILE) + + +def _load_surface(path: str) -> dict | None: + """The persisted surface, or None when this session has never been told + one (missing/corrupt file).""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + skills = data.get("skills") + return skills if isinstance(skills, dict) else None + except (OSError, json.JSONDecodeError): + return None + + +def _save_surface(path: str, surface: dict) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + payload = { + "skills": surface, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + +# -- notice -------------------------------------------------------------------- + + +def _render_notice(catalog, old: dict | None, new: dict) -> str: + summary = "" + try: + summary = catalog.get_skills_summary() or "" + except Exception: + pass + if not summary: + summary = "(no skills are currently available)" + + lines: list[str] = [""] + if old is None: + # First sync of a session that predates the sidecar: the head list + # may be stale and the drift is unknowable — announce the full truth. + lines.append( + "Skill inventory may have changed since this session started. " + "CURRENT full list (authoritative; supersedes the system " + "prompt's list and any earlier notice):") + else: + lines.append( + "Skill inventory updated. CURRENT full list (supersedes the " + "system prompt's list and any earlier notice):") + lines.append(summary) + + if old is not None: + added = sorted(sid for sid in new if sid not in old) + removed = sorted(old[sid].get("name", sid) + for sid in old if sid not in new) + updated = sorted(new[sid].get("name", sid) + for sid in new if sid in old and new[sid] != old[sid]) + if added: + names = ", ".join(new[sid].get("name", sid) for sid in added) + lines.append(f"Newly added since last known state: {names}") + if removed: + lines.append( + "Removed or disabled since last known state: " + + ", ".join(removed)) + if updated: + lines.append( + "Content updated since last known state: " + + ", ".join(updated) + + " — any previously loaded copy (including files under the " + "skill's directory such as references/ or scripts/) is " + "stale; re-read it via skill_view / re-open the files " + "before relying on it.") + lines.append("Do not mention this notice to the user.") + lines.append("") + return "\n".join(lines) + + +def pending_notice(catalog, project, session): + """Compare the current skill surface with what this session was last told. + + Returns ``(notice_text | None, commit)``. ``commit()`` persists the new + surface and MUST be called only after the turn carrying the notice was + actually enqueued (or immediately for the silent brand-new-session init, + where notice_text is None). + """ + path = _surface_path(project, session) + old = _load_surface(path) + new = build_surface(catalog) + + def commit() -> None: + try: + _save_surface(path, new) + except OSError: + logger.warning("skill surface save failed", exc_info=True) + + if old is None: + if not session_has_history(project, session): + # Brand-new session: the head is built from the current catalog + # this very turn — nothing to announce, just start tracking. + commit() + return None, lambda: None + return _render_notice(catalog, None, new), commit + + if old == new: + return None, lambda: None + return _render_notice(catalog, old, new), commit diff --git a/webui/backend/app/backends/ms_agent/skills.py b/webui/backend/app/backends/ms_agent/skills.py new file mode 100644 index 000000000..bbc4c5a61 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/skills.py @@ -0,0 +1,477 @@ +"""Skills adapter. + +Two kinds of skills are surfaced: + * **webui-local** — created in the UI with content; the SDK has no content-skill + model, so these live in the sidecar with full CRUD. + * **source-discovered** — skills found in the **local** dir sources reported by + the SDK's SkillsConfigManager: the per-scope **live tree** (``/skills`` + globally, ``/.ms_agent/skills`` per project — implicit, presence = + registered) plus the explicit local sources in skills.json (remote + modelscope/git sources are skipped here to avoid network in a management + call). Their id encodes the scope + skill_id (prefix ``src::``). + +UI-created skills (bundle imports) are **materialized into the scope's live +tree** — no skills.json entry needed; existence is the filesystem. Explicit +sources remain the path for referencing directories outside the trees. +Enable/disable writes the skill_id to skills.json's ``disabled`` list (state is +file-persisted even though existence isn't); a live session picks changes up at +the next turn via the chat turn-boundary sync. Deleting a tree-resident skill +removes its directory; skills from external sources are delete-protected.""" +from __future__ import annotations + +import base64 +import json +import os +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.schemas.skill import ( + Skill, + SkillCreate, + SkillFile, + SkillFileContent, + SkillUpdate, +) + +_SRC = "src::" +_WEBUI_BUNDLE = "webui.skill.bundle.v1" + + +def _slug(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-").lower() + return slug[:64] or "skill" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _valid_scope(scope: str) -> None: + if scope == "global": + return + if scope.startswith("project:"): + if pm().get(scope.split(":", 1)[1]) is None: + raise BadRequest(f"unknown project: {scope.split(':', 1)[1]}") + return + raise BadRequest(f"invalid scope: {scope!r}") + + +def _bridge_enabled(name: str, enabled: bool, scope: str) -> None: + """Reflect enable/disable into skills.json (honored by the runtime).""" + try: + from ms_agent.config.skills_manager import SkillsConfigManager + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sk.set_skill_enabled(name, enabled, scope="global") + else: + proj = pm().get(scope.split(":", 1)[1]) + if proj is not None: + sk.set_skill_enabled(name, enabled, scope="project", project_path=proj.path) + except Exception: + pass + + +# -- source-discovered skills (local dirs only) -------------------------------- + + +def _enc_src(scope: str, skill_id: str) -> str: + raw = base64.urlsafe_b64encode(f"{scope}\x1f{skill_id}".encode()).decode().rstrip("=") + return _SRC + raw + + +def _dec_src(sid: str) -> tuple[str, str]: + try: + raw = sid[len(_SRC):] + pad = "=" * (-len(raw) % 4) + scope, skill_id = base64.urlsafe_b64decode(raw + pad).decode().split("\x1f", 1) + return scope, skill_id + except Exception: + raise NotFound("skill not found") + + +def _discovered_for_scope(scope: str, project_path: str | None) -> list[tuple]: + """(runtime_skill_id, SkillSchema, enabled) for local sources in the scope.""" + from ms_agent.config.skills_manager import SkillsConfigManager + from ms_agent.skill.loader import SkillLoader + from ms_agent.skill.sources import SkillSourceType, parse_skill_source + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sources = sk.list_sources(scope="global") + disabled = set(sk.load_global().get("disabled", [])) + else: + sources = sk.list_sources(scope="project", project_path=project_path) + disabled = set(sk.load_merged(project_path).get("disabled", [])) + + loader = SkillLoader() + out: list[tuple] = [] + for src_str in sources: + try: + src = parse_skill_source(str(src_str)) + if src.type != SkillSourceType.LOCAL_DIR or not src.path: + continue # skip remote sources — no network in a management call + for loaded_key, skill in (loader.load_skills(src.path) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + enabled = runtime_id not in disabled and loaded_key not in disabled + out.append((runtime_id, skill, enabled)) + except Exception: + continue + return out + + +def _discovered_to_schema(scope: str, skill_id: str, skill, enabled: bool) -> Skill: + return Skill( + id=_enc_src(scope, skill_id), + name=getattr(skill, "name", skill_id) or skill_id, + kind=(getattr(skill, "tags", None) or ["skill"])[0], + content=getattr(skill, "description", "") or "", + enabled=enabled, + scope=scope, + created_at=datetime.now(timezone.utc), + ) + + +def _scopes_to_scan(scope: str | None) -> list[tuple[str, str | None]]: + if scope == "global": + return [("global", None)] + if scope and scope.startswith("project:"): + proj = pm().get(scope.split(":", 1)[1]) + return [(scope, proj.path)] if proj is not None else [] + # all scopes + out: list[tuple[str, str | None]] = [("global", None)] + for proj in pm().list(): + out.append((f"project:{proj.id}", proj.path)) + return out + + +# -- endpoints ----------------------------------------------------------------- + + +def list_skills(scope: str | None = None) -> list[Skill]: + rows = [ + Skill.model_validate(r) + for r in sidecar.section("skills").values() + if scope is None or r.get("scope") == scope + ] + seen = {(r.name, r.scope) for r in rows} + for sc, pp in _scopes_to_scan(scope): + for skill_id, skill, enabled in _discovered_for_scope(sc, pp): + item = _discovered_to_schema(sc, skill_id, skill, enabled) + if (item.name, item.scope) in seen: + continue # a webui-local skill of the same name/scope wins + seen.add((item.name, item.scope)) + rows.append(item) + rows.sort(key=lambda r: (r.scope, r.name)) + return rows + + +def _add_local_source(scope: str, path: str) -> Skill: + """Register a local directory as a skill source so chat actually loads its + skills (SkillsConfigManager -> skills.json -> merge_skills_into_config).""" + from ms_agent.config.skills_manager import SkillsConfigManager + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sk.add_source(path, scope="global") + else: + sk.add_source(path, scope="project", project_path=_project_path(scope)) + # Surface a real discovered skill from the newly added source. Other skills + # from the same source appear on refresh/list. + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(path) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(scope, runtime_id, skill, True) + # nothing discovered yet — return a source marker + return Skill( + id=_enc_src(scope, path), name=os.path.basename(path.rstrip("/")) or path, + kind="source", content=path, enabled=True, scope=scope, + created_at=datetime.now(timezone.utc), + ) + + +def _safe_relpath(path: str) -> Path: + rel = PurePosixPath(path.replace("\\", "/")) + if rel.is_absolute() or ".." in rel.parts or not rel.parts: + raise BadRequest(f"invalid skill file path: {path!r}") + return Path(*rel.parts) + + +def _live_tree(scope: str, *, create: bool = False) -> Path: + """The scope's live skills tree — presence there IS registration. + + Global: ``/skills``. Project: ``/.ms_agent/skills`` (reads + honor the legacy ``.ms-agent`` spelling via the SDK helper; writes always + use the new one).""" + if scope == "global": + root = Path(home()).expanduser() / "skills" + else: + from ms_agent.config.skills_manager import SkillsConfigManager + + proj = pm().get(scope.split(":", 1)[1]) + if proj is None: + raise BadRequest(f"unknown project: {scope.split(':', 1)[1]}") + root = SkillsConfigManager.project_skills_tree(proj.path) + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def _unique_skill_dir(name: str, root: Path) -> Path: + base = _slug(name) + candidate = root / base + if not candidate.exists(): + return candidate + for idx in range(2, 1000): + candidate = root / f"{base}-{idx}" + if not candidate.exists(): + return candidate + return root / f"{base}-{uuid.uuid4().hex[:8]}" + + +def _bundle_files_from_content(content: str) -> list[dict]: + try: + payload = json.loads(content) + except json.JSONDecodeError as exc: + raise BadRequest("invalid skill bundle payload") from exc + if not isinstance(payload, dict) or payload.get("format") != _WEBUI_BUNDLE: + raise BadRequest("invalid skill bundle payload") + files = payload.get("files") + if not isinstance(files, list) or not files: + raise BadRequest("skill bundle must contain files") + return files + + +def _materialize_bundle(body: SkillCreate) -> Skill: + from ms_agent.skill.schema import SkillSchemaParser + + files = _bundle_files_from_content(body.content or "") + skill_md = next( + ( + f + for f in files + if isinstance(f, dict) + and str(f.get("path", "")).replace("\\", "/").split("/")[-1] == "SKILL.md" + ), + None, + ) + if not skill_md: + raise BadRequest("skill bundle must include SKILL.md") + + frontmatter = SkillSchemaParser.parse_yaml_frontmatter(str(skill_md.get("content", ""))) + if not frontmatter or not frontmatter.get("name") or not frontmatter.get("description"): + raise BadRequest("SKILL.md must include name and description frontmatter") + bundle_root = _safe_relpath(str(skill_md.get("path", ""))).parent + + skill_dir = _unique_skill_dir( + str(frontmatter.get("name") or body.name), + root=_live_tree(body.scope, create=True), + ) + skill_dir.mkdir(parents=True, exist_ok=False) + try: + for entry in files: + if not isinstance(entry, dict): + raise BadRequest("invalid skill bundle file") + rel = _safe_relpath(str(entry.get("path", ""))) + if str(bundle_root) != ".": + try: + rel = rel.relative_to(bundle_root) + except ValueError: + continue + content = entry.get("content") + if not isinstance(content, str): + raise BadRequest(f"invalid content for skill file: {rel}") + target = skill_dir / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + # Materialized inside the live tree — presence IS registration, no + # skills.json entry. Surface the discovered skill directly. + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(str(skill_dir)) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(body.scope, runtime_id, skill, True) + raise BadRequest("skill bundle did not produce a loadable skill") + except Exception: + # Leave no half-imported skill behind when validation/write/load fails. + import shutil + + shutil.rmtree(skill_dir, ignore_errors=True) + raise + + +def create_skill(body: SkillCreate) -> Skill: + _valid_scope(body.scope) + if body.kind == "bundle": + return _materialize_bundle(body) + + # If `content` is an existing local directory, treat it as a skill SOURCE + # (so chat loads it) rather than a content-only sidecar skill. A directory + # already inside the scope's live tree is registered by presence — don't + # add a redundant skills.json entry, just surface what the scan sees. + candidate = os.path.expanduser((body.content or "").strip()) + if candidate and os.path.isdir(candidate): + if _in_live_tree(body.scope, Path(candidate)): + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(candidate) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(body.scope, runtime_id, skill, True) + raise BadRequest("no loadable skill found in directory") + return _add_local_source(body.scope, candidate) + if body.kind == "source": + raise BadRequest("local skill source must be an existing directory") + + sid = "sk-" + uuid.uuid4().hex[:12] + row = { + "id": sid, "name": body.name, "kind": body.kind, "content": body.content, + "enabled": body.enabled, "scope": body.scope, "created_at": _now(), + } + sidecar.put("skills", sid, row) + _bridge_enabled(body.name, body.enabled, body.scope) + return Skill.model_validate(row) + + +def get_skill(sid: str) -> Skill: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, enabled in _discovered_for_scope(scope, pp): + if s == skill_id: + return _discovered_to_schema(scope, skill_id, skill, enabled) + raise NotFound("skill not found") + row = sidecar.get("skills", sid) + if not row: + raise NotFound("skill not found") + return Skill.model_validate(row) + + +def _skill_dir_for(sid: str) -> Path | None: + """Resolve the on-disk directory of a discovered (``src::``) skill; None + for sidecar-only skills (single markdown body, no directory).""" + if not sid.startswith(_SRC): + return None + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, _enabled in _discovered_for_scope(scope, pp): + if s != skill_id: + continue + p = Path(str(getattr(skill, "skill_path", "") or "")) + if p.is_file(): # some loaders point at SKILL.md itself + p = p.parent + return p if p.is_dir() else None + raise NotFound("skill not found") + + +_SKIP_TREE_PARTS = {".git", "__pycache__", ".DS_Store", "node_modules"} + + +def list_skill_files(sid: str) -> list[SkillFile]: + """REAL relative file listing of the skill's directory (SKILL.md first). + Sidecar-only skills expose just their markdown body as SKILL.md.""" + root = _skill_dir_for(sid) + if root is None: + get_skill(sid) # 404 for unknown ids + return [SkillFile(path="SKILL.md")] + out: list[SkillFile] = [] + for p in sorted(root.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(root) + if any(part in _SKIP_TREE_PARTS or part.startswith(".") + for part in rel.parts): + continue + try: + size = p.stat().st_size + except OSError: + size = None + out.append(SkillFile(path=rel.as_posix(), size=size)) + # SKILL.md first, then alphabetical — mirrors what the viewer opens first. + out.sort(key=lambda f: (f.path != "SKILL.md", f.path)) + return out + + +def read_skill_file(sid: str, path: str) -> SkillFileContent: + """UTF-8 content of one file inside the skill directory (path-traversal + safe). ``content=None`` flags a binary file.""" + rel = _safe_relpath(path) + root = _skill_dir_for(sid) + if root is None: + sk = get_skill(sid) + if rel.as_posix() == "SKILL.md": + return SkillFileContent(path="SKILL.md", content=sk.content) + raise NotFound("file not found") + f = root / rel + if not f.is_file(): + raise NotFound("file not found") + try: + return SkillFileContent(path=rel.as_posix(), + content=f.read_text("utf-8")) + except (UnicodeDecodeError, ValueError): + return SkillFileContent(path=rel.as_posix(), content=None) + + +def update_skill(sid: str, body: SkillUpdate) -> Skill: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + if body.enabled is not None: + _bridge_enabled(skill_id, body.enabled, scope) # only enable/disable + return get_skill(sid) + row = sidecar.get("skills", sid) + if not row: + raise NotFound("skill not found") + for field in ("name", "kind", "content", "enabled"): + value = getattr(body, field) + if value is not None: + row[field] = value + sidecar.put("skills", sid, row) + if body.enabled is not None: + _bridge_enabled(row["name"], row["enabled"], row["scope"]) + return Skill.model_validate(row) + + +def delete_skill(sid: str) -> None: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, _enabled in _discovered_for_scope(scope, pp): + if s != skill_id: + continue + skill_path = Path(getattr(skill, "skill_path", "") or "") + if skill_path and _in_live_tree(scope, skill_path): + # Tree-resident: presence is registration, so deletion is + # removing the directory (filesystem = existence truth). + import shutil + + shutil.rmtree(skill_path, ignore_errors=True) + return + break + raise BadRequest( + "skill outside the managed skills tree cannot be deleted; " + "disable it or remove its source" + ) + if not sidecar.get("skills", sid): + raise NotFound("skill not found") + sidecar.drop("skills", sid) + + +def _in_live_tree(scope: str, path: Path) -> bool: + """True when *path* resolves inside the scope's live tree (rmtree guard — + relative_to avoids startswith prefix bypasses).""" + try: + path.resolve().relative_to(_live_tree(scope).resolve()) + return True + except (ValueError, OSError): + return False + + +def _project_path(scope: str) -> str | None: + proj = pm().get(scope.split(":", 1)[1]) + return proj.path if proj is not None else None diff --git a/webui/backend/app/backends/ms_agent/titler.py b/webui/backend/app/backends/ms_agent/titler.py new file mode 100644 index 000000000..24298a0de --- /dev/null +++ b/webui/backend/app/backends/ms_agent/titler.py @@ -0,0 +1,178 @@ +"""Agent-side session titling + topic classification. + +On a session's first user message the chat stream asks the LLM to summarize the +message into a short title and pick one topic category (see ``CATEGORIES``). +Both are cheap (one small completion) and best-effort: any failure returns None +so the caller keeps the cheap first-line fallback title and an empty category. + +Credentials/model come from the SDK's seeded ``/settings.json`` ``llm`` +block, falling back to the exported OPENAI_* env. Uses the OpenAI-compatible +``/chat/completions`` endpoint directly (httpx) — no agent runtime needed. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from pathlib import Path + +import httpx + +from app.backends.ms_agent.common import home + +logger = logging.getLogger("app.ms_agent.titler") + +# Fixed topic taxonomy. Kept in sync with the frontend category→icon map +# (ProjectOverviewView). "general" is the fallback for anything uncategorized. +CATEGORIES: tuple[str, ...] = ( + "coding", + "writing", + "research", + "planning", + "data", + "creative", + "media", + "general", +) + +_SYSTEM = ( + "You name a chat and classify its topic from the user's first message. " + 'Reply with ONLY a compact JSON object: {"title": "...", "category": "..."}.\n' + "- title: a short, specific title in the SAME language as the message; no " + "quotes, no ending punctuation; at most ~6 words (or ~16 Chinese characters).\n" + "- category: exactly one of:\n" + " coding (programming, debugging, code), writing (writing or editing text/docs), " + "research (searching or browsing the web for information), planning (plans, todos, " + "scheduling, multi-step tasks), data (data analysis, spreadsheets, charts), " + "creative (brainstorming, ideas, design), media (images, audio, video, or file " + "handling), general (casual chat, Q&A, anything else).\n" + "No prose, no code fences." +) + + +def _llm_config() -> tuple[str, str, str, str]: + """(model, api_key, base_url, protocol) from settings.json llm — plus the + active provider's ``protocol`` override — then OPENAI_* env. + + ``protocol == "anthropic"`` means the active provider speaks the Anthropic + Messages API (e.g. DeepSeek's ``/anthropic`` gateway): posting to + ``/chat/completions`` there 404s, which would silently disable titling.""" + cfg: dict = {} + providers: dict = {} + try: + data = json.loads((Path(home()) / "settings.json").read_text(encoding="utf-8")) + if isinstance(data.get("llm"), dict): + cfg = data["llm"] + if isinstance(data.get("providers"), dict): + providers = data["providers"] + except (OSError, ValueError): + cfg = {} + model = cfg.get("model") or os.environ.get("MS_AGENT_LLM_MODEL") or "" + api_key = cfg.get("api_key") or os.environ.get("OPENAI_API_KEY") or "" + base_url = cfg.get("base_url") or os.environ.get("OPENAI_BASE_URL") or "" + entry = providers.get(str(cfg.get("provider") or "")) + protocol = str(entry.get("protocol") or "") if isinstance(entry, dict) else "" + return str(model), str(api_key), str(base_url), protocol.lower() + + +def _parse(content: str) -> tuple[str, str] | None: + """Extract (title, category) from the model's JSON reply, leniently.""" + if not content: + return None + match = re.search(r"\{.*\}", content, re.DOTALL) + if not match: + return None + try: + obj = json.loads(match.group(0)) + except ValueError: + return None + if not isinstance(obj, dict): + return None + title = str(obj.get("title") or "").strip().strip("\"'").strip() + title = title.splitlines()[0][:60] if title else "" + category = str(obj.get("category") or "").strip().lower() + if category not in CATEGORIES: + category = "general" + if not title: + return None + return title, category + + +def _anthropic_text(data: dict) -> str: + """The first text block of an Anthropic Messages response (a thinking-mode + gateway may put a thinking block before it).""" + for block in data.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return str(block.get("text") or "") + return "" + + +async def generate_title_and_category(text: str) -> tuple[str, str] | None: + """Summarize the first user message into (title, category), or None on any + failure (missing creds/model, network error, unparseable reply). Speaks the + active provider's wire protocol: Anthropic Messages when its ``protocol`` + override says so, OpenAI-compatible chat/completions otherwise.""" + text = (text or "").strip() + if not text: + return None + model, api_key, base_url, protocol = _llm_config() + if not (model and api_key and base_url): + return None + if protocol == "anthropic": + url = base_url.rstrip("/") + "/v1/messages" + headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"} + payload = { + "model": model, + "system": _SYSTEM, + "messages": [{"role": "user", "content": text[:2000]}], + "temperature": 0.2, + "max_tokens": 600, + # Thinking-default gateways (DeepSeek /anthropic) otherwise spend + # the whole budget on a thinking block for a long first message and + # return no text block at all — the observed intermittent-title + # failure. Explicitly off; standard Anthropic accepts this too. + "thinking": {"type": "disabled"}, + } + else: + url = base_url.rstrip("/") + "/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + payload = { + "model": model, + "messages": [ + {"role": "system", "content": _SYSTEM}, + {"role": "user", "content": text[:2000]}, + ], + "temperature": 0.2, + "max_tokens": 160, + # Qwen thinking models require thinking off for non-streaming calls; + # OpenAI-compatible servers ignore the extra field. + "enable_thinking": False, + } + # One retry after a beat: transient gateway hiccups were observed live. + # Credentials/config problems returned above never reach this loop, so + # the retry only spends time when a real request was attempted. + for attempt in (1, 2): + try: + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + if protocol == "anthropic": + content = _anthropic_text(data) + else: + content = data["choices"][0]["message"]["content"] + except (httpx.HTTPError, KeyError, ValueError, IndexError) as exc: + logger.warning("titler request failed (attempt %d, %s %s): %s", + attempt, protocol, model, exc) + content = "" + if content: + parsed = _parse(str(content)) + if parsed is not None: + return parsed + logger.warning("titler reply unparseable (attempt %d, %s %s): %r", + attempt, protocol, model, str(content)[:120]) + if attempt == 1: + await asyncio.sleep(2) + return None diff --git a/webui/backend/app/backends/ms_agent/workspace.py b/webui/backend/app/backends/ms_agent/workspace.py new file mode 100644 index 000000000..86bf61e9e --- /dev/null +++ b/webui/backend/app/backends/ms_agent/workspace.py @@ -0,0 +1,321 @@ +"""Workspace adapter — SDK Workspace(project.path) with a flat recursive listing. + +The frontend renders a tree from a flat list of relative paths, so we walk the +project dir (== output_dir) recursively. Framework internals are hidden, but +``.ms_agent`` itself is SHOWN so users can see and manage the project-scoped +state it holds (e.g. finer-grained permission files) — only its pure-machinery +subtrees (the ``snapshots`` git store, transient ``locks``) stay hidden, along +with the top-level ``sessions`` dir (session logs).""" +from __future__ import annotations + +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent.common import pm +from app.core.filetypes import guess_type, is_binary_ext +from app.schemas.workspace import WorkspaceFile, WorkspaceFileCreate, WorkspaceFileUpdate + +_HIDDEN_TOP = {"sessions"} + +# Dot-directories that are framework internals and should never appear in the +# workspace listing. User-facing dot-dirs (like .github) are kept visible. +# NOTE: ``.ms_agent`` is intentionally NOT here — the project-scoped state it +# holds (user memory, and future finer-grained permission files) must be visible +# and hand-manageable, else such files could only ever be auto-added, never +# deleted. Its pure-machinery subtrees (``_HIDDEN_MSA_SUBDIRS``) and its +# machine-format memory dumps (``_HIDDEN_MSA_MEMORY_SUFFIXES``) are hidden +# separately. +# The bare-root ``.locks/.ms_agent_artifacts/.index/.temp`` are the SDK's LEGACY +# spots (see ms_agent/project/paths.py — all relocated under .ms_agent/) — kept +# here as a safety net for workspaces written by older SDKs / tools that still +# litter the workspace root. +_HIDDEN_DOT = { + ".git", ".ms_agent_webui", ".venv", "__pycache__", + ".locks", ".ms_agent_artifacts", ".index", ".temp", +} + +# Subtrees directly under the (now visible) ``.ms_agent`` dir that are pure +# machinery and don't belong in the raw file tree at all: +# - ``snapshots``: the git object store for workspace diffing (per-file +# browsing/deletion would corrupt the snapshot history); +# - ``locks``: transient file locks (deleting a live lock disrupts writes). +# (``memory`` is NOT here — it's shown, but its machine-format dumps are hidden +# by suffix; see ``_HIDDEN_MSA_MEMORY_SUFFIXES``.) +_HIDDEN_MSA_SUBDIRS = {"snapshots", "locks"} + +# ``.ms_agent/memory/`` holds BOTH the user's memory (visible, hand-editable, +# reloaded by the SDK in-project — ``MEMORY.md``, mem0 store subdirs) AND the +# SDK's machine-format state dumps. The dumps are ``.yaml`` + ``.json`` +# PAIRS written by utils.save_history for the main agent (``Agent-default``) and +# for EVERY agent-tool sub-agent (``worker-``, … — the tag/prefix is +# arbitrary and grows as more agent tools run). They serialize message history + +# the resolved agent config (which INCLUDES secrets like API keys) and are +# rewritten each turn — SDK state, not user content. So keep memory/ visible but +# hide any ``.yaml``/``.json`` under it (matches every tag, current and future), +# leaving ``MEMORY.md`` and other human-readable memory files in view. +_HIDDEN_MSA_MEMORY_SUFFIXES = {".yaml", ".yml", ".json"} + +# Upper bound on inline file content returned by GET (the editor preview only +# needs a readable slice; larger files stay listable but preview-truncated). +_MAX_PREVIEW_BYTES = 512 * 1024 + + +def _project_path(pid: str) -> str: + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + return proj.path + + +def _ws(pid: str): + from ms_agent.project import Workspace + + return Workspace(_project_path(pid)) + + +def _safe(ws, rel: str) -> Path: + target = (ws.root / rel).resolve() + try: + target.relative_to(ws.root) + except ValueError: + raise BadRequest("path traversal blocked") + return target + + +def _hidden(rel: Path) -> bool: + parts = rel.parts + if parts and parts[0] in _HIDDEN_TOP: + return True + # Only hide specific internal dot-dirs, not all dot-prefixed paths + # (user-facing dirs like .github, .vscode, .env files are kept visible). + if any(p in _HIDDEN_DOT for p in parts): + return True + # Under .ms_agent: hide the pure-machinery subtrees (snapshots/locks) whole, + # and — inside the otherwise-visible memory/ — the SDK's .yaml/.json + # state dumps (any tag), keeping user memory (MEMORY.md, …) in view. + for i in range(len(parts) - 1): + if parts[i] != ".ms_agent": + continue + if parts[i + 1] in _HIDDEN_MSA_SUBDIRS: + return True + if (parts[i + 1] == "memory" + and rel.suffix.lower() in _HIDDEN_MSA_MEMORY_SUFFIXES): + return True + return False + + +def _entry(pid: str, root: Path, target: Path) -> WorkspaceFile: + stat = target.stat() + is_dir = target.is_dir() + return WorkspaceFile( + project_id=pid, + path=str(target.relative_to(root)), + kind="folder" if is_dir else "file", + # A directory's own inode size is meaningless to a user, so it reports + # the RECURSIVE total of the files it contains (see `_fill_dir_sizes` / + # `_dir_size`); the raw stat size is only used for files. + size=0 if is_dir else stat.st_size, + updated_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + preview=None, + content_type=None if is_dir else guess_type(target.name), + ) + + +def _fill_dir_sizes(entries: list[WorkspaceFile]) -> None: + """Roll each file's size up into every ancestor folder entry, in place. + + Single pass over the already-collected listing (no extra disk walk): a + folder ends up reporting the total bytes of its whole subtree instead of a + bare 0, which reads as "empty" next to its non-empty children. + """ + folders = {e.path: e for e in entries if e.kind == "folder"} + if not folders: + return + for e in entries: + if e.kind == "folder": + continue + parent = PurePosixPath(e.path.replace("\\", "/")).parent + while str(parent) not in (".", "/", ""): + folder = folders.get(str(parent)) + if folder is not None: + folder.size += e.size + parent = parent.parent + + +def _dir_size(target: Path) -> int: + """Recursive byte total of a directory (single-entry reads).""" + total = 0 + for p in target.rglob("*"): + try: + if p.is_file(): + total += p.stat().st_size + except OSError: + continue + return total + + +def list_files(pid: str) -> list[WorkspaceFile]: + ws = _ws(pid) + root = ws.root + out: list[WorkspaceFile] = [] + for target in root.rglob("*"): + rel = target.relative_to(root) + if _hidden(rel): + continue + out.append(_entry(pid, root, target)) + _fill_dir_sizes(out) + out.sort(key=lambda f: f.path) + return out + + +def create_file(pid: str, body: WorkspaceFileCreate) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, body.path) + if target.exists(): + raise Conflict("file already exists") + if body.kind == "folder": + target.mkdir(parents=True, exist_ok=True) + else: + ws.write_file(body.path, body.content) + return _entry(pid, ws.root, target) + + +def get_file(pid: str, path: str) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, path) + # A path hidden from the listing must not be readable by direct path either + # (else e.g. an .ms_agent/memory dump — which embeds API keys — would still + # leak via a guessed URL). 404 so hidden files' existence isn't revealed. + if _hidden(Path(path)): + raise NotFound("file not found") + if not target.exists(): + raise NotFound("file not found") + entry = _entry(pid, ws.root, target) + if target.is_dir(): + # Match the listing: a folder reports its subtree's total bytes. + entry.size = _dir_size(target) + return entry + if target.is_file() and not is_binary_ext(path): + # Known-binary extensions (archives, media, executables, …) are never + # inlined as text — not even when their bytes happen to decode (e.g. an + # archive an older buggy upload corrupted into a lossy text blob). The + # frontend renders/downloads them via .../raw instead. + try: + text = ws.read_file(path) + entry.preview = text[:200] + # Cap the inline content so a huge file can't bloat the response; + # the editor preview only needs a readable slice. + entry.content = text[:_MAX_PREVIEW_BYTES] + except (UnicodeDecodeError, OSError): + entry.preview = None + entry.content = None + return entry + + +def update_file(pid: str, path: str, body: WorkspaceFileUpdate) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, path) + if not target.is_file(): + raise NotFound("file not found") + ws.write_file(path, body.content) + return _entry(pid, ws.root, target) + + +def delete_file(pid: str, path: str) -> None: + ws = _ws(pid) + target = _safe(ws, path) + if not target.exists(): + raise NotFound("file not found") + ws.delete(path) + + +def move_file(pid: str, src: str, dst: str) -> WorkspaceFile: + """Rename/move ``src`` to ``dst`` (both workspace-relative). Works for files + and folders (children move along). Refuses to clobber an existing target or + to move a folder into its own subtree.""" + ws = _ws(pid) + s = _safe(ws, src) + d = _safe(ws, dst) + if not s.exists(): + raise NotFound("file not found") + if d == s: + return _entry(pid, ws.root, s) + if d.exists(): + raise Conflict("target already exists") + if s.is_dir(): + # Block moving a folder into itself or a descendant (would recurse). + try: + d.relative_to(s) + raise BadRequest("cannot move a folder into itself") + except ValueError: + pass + d.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(s), str(d)) + return _entry(pid, ws.root, d) + + +def _dedup_target(base: Path, data: bytes) -> Path: + """Non-clobbering destination for a chat upload under ``user_files/``. + + The FIRST upload of a given browser file name keeps that name as-is. A + LATER upload of the same name is compared byte-for-byte against that first + file: identical bytes (re-upload of the same file) REUSE it (no new file); + different bytes are stored timestamped (``-``) so they + never overwrite the first. A same-millisecond collision falls back to a + counter. + """ + if not base.exists(): + return base + try: + if base.read_bytes() == data: + return base # identical re-upload of the first file → reuse + except OSError: + pass + ts = int(time.time() * 1000) + stem, suffix, parent = base.stem, base.suffix, base.parent + cand = parent / f"{stem}-{ts}{suffix}" + i = 1 + while cand.exists(): + cand = parent / f"{stem}-{ts}-{i}{suffix}" + i += 1 + return cand + + +def save_upload(pid: str, rel: str, data: bytes, dedup: bool = False) -> WorkspaceFile: + """Persist a raw uploaded file (binary-safe) under the workspace. + + Uploads write bytes directly instead of going through the text-only + ``write_file`` so images/archives/etc. aren't corrupted by UTF-8 coercion. + By default a same-path file is overwritten (explicit-path uploads / import). + With ``dedup`` (chat attachments landing flat in ``user_files/``) the first + upload of a name keeps it; a later same-named upload reuses that first file + when the bytes are identical, else is timestamped + (``-``), so the returned ``path`` is the real location + the caller must use for links + agent references. + """ + ws = _ws(pid) + if not rel: + raise BadRequest("missing file path") + base = _safe(ws, rel) + if base.is_dir(): + raise Conflict("a folder already exists at this path") + target = _dedup_target(base, data) if dedup else base + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return _entry(pid, ws.root, target) + + +def raw_file(pid: str, path: str) -> tuple[Path, str]: + """Resolve an existing file to (path, mime) for raw byte serving.""" + ws = _ws(pid) + target = _safe(ws, path) + # Same guard as get_file: never serve the bytes of a listing-hidden file + # (e.g. an .ms_agent/memory state dump with embedded secrets) by direct path. + if _hidden(Path(path)): + raise NotFound("file not found") + if not target.is_file(): + raise NotFound("file not found") + return target, guess_type(target.name) or "application/octet-stream" diff --git a/webui/backend/app/core/__init__.py b/webui/backend/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/core/envelope.py b/webui/backend/app/core/envelope.py new file mode 100644 index 000000000..61f687d60 --- /dev/null +++ b/webui/backend/app/core/envelope.py @@ -0,0 +1,107 @@ +"""Uniform API response envelope for all non-chat (RESTful CRUD) endpoints. + +Every successful management response is wrapped as:: + + {"code": 0, "message": "success", "data": } + +and every error (HTTPException, validation error, or unhandled crash) as:: + + {"code": , "message": "", "data": null} + +HTTP status codes are preserved so REST semantics stay intact; the envelope +adds a stable, structured shape the frontend can rely on. The chat SSE stream +is intentionally excluded — it owns its own wire format. +""" +from __future__ import annotations + +import json +from typing import Any, Callable + +from fastapi import FastAPI, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from starlette.exceptions import HTTPException as StarletteHTTPException + + +def _envelope(data: Any = None, *, code: int = 0, + message: str = "success") -> dict[str, Any]: + return {"code": code, "message": message, "data": data} + + +def success_response(data: Any, status_code: int = 200) -> JSONResponse: + return JSONResponse(_envelope(data), status_code=status_code) + + +def error_response(status_code: int, message: str, *, + data: Any = None) -> JSONResponse: + return JSONResponse( + _envelope(data, code=status_code, message=message), + status_code=status_code, + ) + + +class EnvelopeRoute(APIRoute): + """Wraps a route's serialized success payload into the standard envelope. + + Runs after FastAPI has already validated/serialized the return value + against `response_model`, so route signatures and validation are untouched. + Errors raised inside the endpoint bypass this and are handled by the + registered exception handlers below. + """ + + def get_route_handler(self) -> Callable: + original = super().get_route_handler() + + async def custom(request: Request) -> Response: + response = await original(request) + # Skip streaming or bodiless-by-design responses we can't buffer. + raw = getattr(response, "body", None) + if raw is None: + return response + media = response.headers.get("content-type", "") + # Only unwrap JSON payloads. A 204 delete has an empty body and no + # JSON content-type — treat it as `data: null`. + if raw and "application/json" not in media: + return response + data = json.loads(raw) if raw else None + # Preserve the RESTful status code (e.g. 201 Created). A 204 becomes + # 200 since the envelope now carries a body. + status = 200 if response.status_code == 204 else response.status_code + return success_response(data, status_code=status) + + return custom + + +async def _http_exception_handler( + request: Request, exc: StarletteHTTPException) -> JSONResponse: + detail = exc.detail + message = detail if isinstance(detail, str) else "request failed" + return error_response(exc.status_code, message, data=None) + + +async def _validation_exception_handler( + request: Request, exc: RequestValidationError) -> JSONResponse: + errors = exc.errors() + message = "validation error" + if errors: + first = errors[0] + loc = ".".join( + str(p) for p in first.get("loc", []) if p not in ("body", "query")) + msg = first.get("msg", "validation error") + message = f"{loc}: {msg}" if loc else msg + # Keep the raw error list in `data` for debugging / field-level UIs. + return error_response(422, message, data=errors) + + +async def _unhandled_exception_handler( + request: Request, exc: Exception) -> JSONResponse: + return error_response(500, "internal server error", data=None) + + +def register_exception_handlers(app: FastAPI) -> None: + """Install envelope-shaped handlers for HTTP, validation, and crash errors.""" + app.add_exception_handler(StarletteHTTPException, _http_exception_handler) + app.add_exception_handler(RequestValidationError, + _validation_exception_handler) + app.add_exception_handler(Exception, _unhandled_exception_handler) diff --git a/webui/backend/app/core/filetypes.py b/webui/backend/app/core/filetypes.py new file mode 100644 index 000000000..7306c61ff --- /dev/null +++ b/webui/backend/app/core/filetypes.py @@ -0,0 +1,70 @@ +"""Shared file-type classification for the workspace preview. + +The frontend picks a preview by MIME type + whether the file is text-decodable: +text -> Monaco, image/video/audio -> media element, else -> unsupported. Two +quirks are handled centrally here so both the ms_agent and mock backends agree: + +* ``mimetypes`` maps a few *source* extensions to non-text MIME types — most + notably ``.ts`` -> ``video/mp2t`` — which would mis-flag TypeScript as video. + ``guess_type`` overrides those so content_type stays trustworthy. +* Some extensions are *always* binary containers (archives, executables, media, + fonts, office docs). Their bytes must never be shown as text even if they + happen to decode — e.g. an archive that an older buggy upload corrupted into a + lossy text blob. ``is_binary_ext`` flags them so callers skip inline content. +""" +from __future__ import annotations + +import mimetypes +from pathlib import Path + +# Source/text extensions that ``mimetypes`` resolves to a media MIME type. +# Overridden to a text type so media detection never trips on them. +_TEXT_TYPE_OVERRIDES = { + ".ts": "text/typescript", + ".mts": "text/typescript", + ".cts": "text/typescript", +} + +# Extensions whose contents are always binary and must not be inlined as text. +# Media is included so those files are served via .../raw and rendered, never +# poured into the code editor. (`.ts` is intentionally absent — in a code +# workspace it's TypeScript, not an MPEG transport stream.) +_BINARY_EXTS = { + # archives / compression + ".zip", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".tar", ".jar", + ".war", ".whl", ".lz", ".lzma", ".cab", ".deb", ".rpm", + # executables / libraries / bytecode + ".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".pyc", ".pyo", + ".wasm", ".msi", ".apk", ".dex", + # disk images + ".iso", ".dmg", ".img", + # documents (binary containers) + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", + ".ods", ".odp", + # fonts + ".ttf", ".otf", ".woff", ".woff2", ".eot", + # databases + ".sqlite", ".db", ".mdb", + # images + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", + ".tiff", ".avif", ".heic", ".svg", + # video + ".mp4", ".webm", ".mov", ".m4v", ".mkv", ".avi", ".ogv", ".mpg", + ".mpeg", ".flv", ".wmv", + # audio + ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".opus", + ".mid", ".midi", +} + + +def guess_type(name: str) -> str | None: + """Best-effort MIME type, with source-extension overrides applied.""" + ext = Path(name).suffix.lower() + if ext in _TEXT_TYPE_OVERRIDES: + return _TEXT_TYPE_OVERRIDES[ext] + return mimetypes.guess_type(name)[0] + + +def is_binary_ext(name: str) -> bool: + """True for extensions that must never be previewed as editable text.""" + return Path(name).suffix.lower() in _BINARY_EXTS diff --git a/webui/backend/app/core/model_discovery.py b/webui/backend/app/core/model_discovery.py new file mode 100644 index 000000000..43998d82f --- /dev/null +++ b/webui/backend/app/core/model_discovery.py @@ -0,0 +1,54 @@ +"""Discover available model ids from a provider's standard /models endpoint. + +Best-effort only: any failure (missing key, network error, non-standard +endpoint, non-2xx) degrades silently to an empty list so the UI can fall back +to free-form manual input. +""" +from __future__ import annotations + +import httpx + + +def _parse_ids(payload: object) -> list[str]: + """Extract model ids from a standard OpenAI/Anthropic /models response. + + Both protocols return `{"data": [{"id": "..."}, ...]}`. + """ + ids: list[str] = [] + if isinstance(payload, dict): + data = payload.get("data") + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + mid = item.get("id") + if isinstance(mid, str) and mid: + ids.append(mid) + return sorted(set(ids)) + + +def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> list[str]: + """Return available model ids for a provider, or [] on any failure.""" + if not base_url: + return [] + base = base_url.rstrip("/") + try: + if protocol == "anthropic": + if not base.endswith("/v1"): + base = f"{base}/v1" + url = f"{base}/models" + headers = {"anthropic-version": "2023-06-01"} + if api_key: + headers["x-api-key"] = api_key + else: + url = f"{base}/models" + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + with httpx.Client(timeout=8) as client: + resp = client.get(url, headers=headers) + if resp.status_code // 100 != 2: + return [] + return _parse_ids(resp.json()) + except Exception: + return [] diff --git a/webui/backend/app/core/settings.py b/webui/backend/app/core/settings.py new file mode 100644 index 000000000..a93c95a4d --- /dev/null +++ b/webui/backend/app/core/settings.py @@ -0,0 +1,85 @@ +import os +from pathlib import Path + +from dotenv import dotenv_values +from pydantic_settings import BaseSettings, SettingsConfigDict + +# app/core/settings.py -> backend/ ; anchor .env to the file, not the CWD, so it +# loads identically from the server, a script, or a test regardless of cwd. +_BACKEND_DIR = Path(__file__).resolve().parents[2] +# This backend runs in two directory layouts and must resolve its dotenv chain +# without adaptation in either one: +# standalone checkout: /backend -> repo/.env, backend/.env +# embedded in ms-agent: /webui/backend -> repo/.env, webui/.env, +# backend/.env +# The embedded layout inserts one directory level, so the repository root -- +# where shared provider credentials live -- sits one level higher. Detect it by +# the parent directory's name; walking further up unconditionally would read a +# stray .env from OUTSIDE the checkout in the standalone layout. +_IS_EMBEDDED = _BACKEND_DIR.parent.name == "webui" +_ENV_FILES = ( + ( + _BACKEND_DIR.parent.parent / ".env", + _BACKEND_DIR.parent / ".env", + _BACKEND_DIR / ".env", + ) if _IS_EMBEDDED else ( + _BACKEND_DIR.parent / ".env", + _BACKEND_DIR / ".env", + )) + +# Publish all supported .env files into os.environ (never overriding real +# exports). Later, more specific files win while merging: repository defaults +# < (webui shared values, embedded layout only) < backend-only values. MCP +# ${VAR} placeholders need +# these values in os.environ rather than only in pydantic's settings object. +# pydantic-settings only extracts its own declared fields; MCP ${VAR} +# placeholders (headers/args/env in mcp.json) resolve against os.environ at +# connection time, so keys like DASHSCOPE_API_KEY must actually be there. +_dotenv: dict[str, str] = {} +for _env_file in _ENV_FILES: + if _env_file.is_file(): + _dotenv.update({ + key: value + for key, value in dotenv_values(_env_file).items() + if value is not None + }) +for _key, _value in _dotenv.items(): + os.environ.setdefault(_key, _value) + + +class Settings(BaseSettings): + # Process environment variables win over dotenv files. Within the files, + # the later, more specific file wins (repo < webui < backend). + model_config = SettingsConfigDict( + env_file=tuple(str(path) for path in _ENV_FILES), + env_file_encoding="utf-8", + extra="ignore", + ) + + host: str = "127.0.0.1" + port: int = 8000 + + cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + + anthropic_api_key: str = "" + openai_api_key: str = "" + openai_base_url: str = "" + + # --- ms_agent backend --- + # Override the SDK global home (default ~/.ms_agent). Maps to MS_AGENT_HOME. + ms_agent_home: str = "" + # Bootstrap the SDK's settings.json `llm` block on first run when absent, so + # ConfigResolver yields a working model. Credentials reuse openai_api_key / + # openai_base_url. provider must be a known registry id (openai, modelscope, + # dashscope, anthropic, ...). + ms_agent_llm_provider: str = "openai" + ms_agent_llm_model: str = "" + # Optional third-party key passed through to the SDK env (e.g. web-search MCP). + exa_api_key: str = "" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py new file mode 100644 index 000000000..cff940099 --- /dev/null +++ b/webui/backend/app/main.py @@ -0,0 +1,96 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import ( + agent_settings, + chat, + instructions, + mcps, + memory, + models, + presence, + profile, + projects, + providers, + sessions, + skills, + workspace, +) +from app.core.envelope import register_exception_handlers +from app.core.settings import settings + + +def create_app() -> FastAPI: + app = FastAPI(title="ms-agent-webui backend", version="0.2.0") + + # Uniform envelope-shaped errors for every route (HTTP / validation / crash). + register_exception_handlers(app) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Ensure ~/.ms_agent + default project + settings.json llm are ready. + from app.backends.ms_agent.bootstrap import bootstrap + + bootstrap() + + @app.on_event("shutdown") + async def _shutdown() -> None: + from app.backends.ms_agent.runtime import registry + + await registry.close_all() + + app.include_router(chat.router) + app.include_router(presence.router) + app.include_router(projects.router) + app.include_router(sessions.router) + app.include_router(mcps.router) + app.include_router(skills.router) + app.include_router(memory.router) + app.include_router(instructions.router) + app.include_router(providers.router) + app.include_router(models.router) + app.include_router(agent_settings.router) + app.include_router(profile.router) + app.include_router(workspace.router) + + @app.get("/api/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + return app + + +app = create_app() + + +def _run(reload: bool) -> None: + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.host, + port=settings.port, + reload=reload, + reload_dirs=["app"] if reload else None, + reload_includes=["*.py", "*.env"] if reload else None, + ) + + +def dev() -> None: + """Run with hot reload.""" + _run(reload=True) + + +def serve() -> None: + """Run without reload (production).""" + _run(reload=False) + + +if __name__ == "__main__": + dev() diff --git a/webui/backend/app/schemas/__init__.py b/webui/backend/app/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/schemas/agent_settings.py b/webui/backend/app/schemas/agent_settings.py new file mode 100644 index 000000000..aca76c2f2 --- /dev/null +++ b/webui/backend/app/schemas/agent_settings.py @@ -0,0 +1,37 @@ +from typing import Literal + +from pydantic import BaseModel + + +MemoryBackend = Literal["file", "vector"] + +# Where vector-memory embeddings come from: +# - "provider": an OpenAI-compatible /embeddings endpoint. Which one is +# resolved from memory_embed_provider_id, falling back to the conversation +# provider when unset (and to the local model when THAT provider serves no +# embeddings — about half of them don't). +# - "local": fastembed ONNX model on this machine, no network. +MemoryEmbedMode = Literal["provider", "local"] + + +class AgentSettings(BaseModel): + default_provider_id: str | None = None + default_model_id: str | None = None + + # Inherited by newly-created projects. + default_memory_enabled: bool = True + default_memory_backend: MemoryBackend = "file" + + # Vector-memory model configuration. All None = follow the conversation + # model/provider — explicit values pin fact extraction / embeddings to a + # model of the user's choosing, independent of what chat uses. + memory_llm_provider_id: str | None = None + memory_llm_model: str | None = None + memory_embed_mode: MemoryEmbedMode = "provider" + memory_embed_provider_id: str | None = None + memory_embed_model: str | None = None + memory_recall_top_k: int | None = None + + # Global auto-attach masters — projects can override per-scope. + global_mcp_auto_attach: bool = True + global_skill_auto_attach: bool = True diff --git a/webui/backend/app/schemas/chat.py b/webui/backend/app/schemas/chat.py new file mode 100644 index 000000000..8aeeb3ac1 --- /dev/null +++ b/webui/backend/app/schemas/chat.py @@ -0,0 +1,79 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class ChatFile(BaseModel): + """A file the user attached to a turn. Uploaded to the project workspace + under ``user_files/`` before the chat request is sent, so ``path`` is the + workspace-relative location the agent reads with its file tools; ``url`` is + the raw HTTP link used only by the frontend to preview the file.""" + + name: str + path: str # workspace-relative, e.g. "user_files/report.pdf" + url: str | None = None # raw byte URL (frontend preview only) + size: int | None = None + # 'file' | 'image' | 'audio' | 'video' — drives the user-bubble card render. + type: str | None = None + + +class ContentSegment(BaseModel): + """One segment of a configuration-style message content array. + + - ``{"type": "text", "text": "..."}`` — plain typed text; + - ``{"type": "skill", "id": "writer"}`` — a skill invocation picked in the + composer. The backend expands the first skill via the SDK bridge; the + same shape is echoed back by session history so the frontend can + re-render the skill pill. + """ + + type: Literal["text", "skill"] + text: str = "" # type == "text" + id: str = "" # type == "skill" + name: str = "" # type == "skill": display name the user saw (e.g. slug) + + +class ChatMessage(BaseModel): + role: Literal["user", "assistant", "system"] + # Configuration-style content: a plain string, or an ordered segment array + # (text + inline skill invocations — no separate skills field needed). + content: str | list[ContentSegment] = "" + files: list[ChatFile] = Field(default_factory=list) + # Deprecated: legacy structured skill ids. Prefer inline + # ``{"type": "skill"}`` segments in ``content``. + skills: list[str] = Field(default_factory=list) + + +class ChatRequest(BaseModel): + session_id: str | None = None + project_id: str | None = None + # The CURRENT turn's user message. Conversation context is NOT taken from + # the request — the ms-agent SessionLog on disk is the source of truth. + message: ChatMessage | None = None + + +class ChatChunk(BaseModel): + """One streamed SSE frame; `type` routes frontend rendering. Mirrors + frontend/app/lib/agentProvider.ts::AgentChunk (task/step view-model). + + - text: markdown token appended to the assistant body + - thought: one reasoning block (meta.duration → the "thinking Ns" header) + - task: declares/updates a todo task (meta: id, label, status) + - step: a step card nested under a task (meta: kind, task_id?, status?) + - session: emitted once at turn start when the session exists (meta: + session_id, project_id) so the client can refresh its lists + immediately, before the turn (and title) complete + - turn: server-authoritative age of the RUNNING turn (meta: elapsed_ms). + Sent first on every stream (including a re-attach, whose client + has no idea when the turn actually started) and re-sent + periodically, so the "processing Ns" counter survives a refresh + and can't drift from the server clock. + - error: a turn/API error (meta: message, recoverable); display-only + - done: stream terminator (meta: session_id, project_id, title?, category?) + """ + + type: Literal[ + "text", "thought", "task", "step", "session", "turn", "error", "done" + ] + content: str = "" + meta: dict = Field(default_factory=dict) diff --git a/webui/backend/app/schemas/instruction.py b/webui/backend/app/schemas/instruction.py new file mode 100644 index 000000000..50a94121a --- /dev/null +++ b/webui/backend/app/schemas/instruction.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class Instruction(BaseModel): + scope: str + content: str = "" + updated_at: datetime + + +class InstructionUpsert(BaseModel): + content: str = "" diff --git a/webui/backend/app/schemas/mcp.py b/webui/backend/app/schemas/mcp.py new file mode 100644 index 000000000..be7d209b9 --- /dev/null +++ b/webui/backend/app/schemas/mcp.py @@ -0,0 +1,66 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +Transport = Literal["stdio", "http", "sse", "streamable_http"] + + +class Mcp(BaseModel): + id: str + name: str + description: str = "" + transport: Transport = "stdio" + endpoint: str + enabled: bool = True + scope: str # 'global' | 'project:' + # Structured connection extras (round-tripped by the ms_agent backend): + # env for stdio servers, headers for remote (sse/http) servers. + env: dict = Field(default_factory=dict) + headers: dict = Field(default_factory=dict) + created_at: datetime + + +class McpCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + description: str = "" + transport: Transport = "stdio" + endpoint: str = Field(min_length=1) + enabled: bool = True + scope: str + env: dict = Field(default_factory=dict) + headers: dict = Field(default_factory=dict) + + +class McpReplace(BaseModel): + """Desired full contents of one scope, in display order. + + Used by the raw-JSON editor: the document it saves IS the whole scope, so it + is applied as a single atomic replace rather than a delete-all + re-create + sequence from the client. + """ + + scope: str + servers: list[McpCreate] = Field(default_factory=list) + + +class McpUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = None + transport: Transport | None = None + endpoint: str | None = Field(default=None, min_length=1) + enabled: bool | None = None + env: dict | None = None + headers: dict | None = None + + +class McpHealth(BaseModel): + """Live reachability of an enabled MCP server (real connect+initialize). + `id` matches the Mcp row so the frontend can join. When healthy is False, + `error` is a short reason (e.g. 'Session terminated', 'timed out').""" + id: str + name: str + scope: str + healthy: bool + error: str | None = None diff --git a/webui/backend/app/schemas/memory.py b/webui/backend/app/schemas/memory.py new file mode 100644 index 000000000..6f7fca08a --- /dev/null +++ b/webui/backend/app/schemas/memory.py @@ -0,0 +1,81 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class MemoryItem(BaseModel): + id: str + project_id: str + content: str = "" + updated_at: datetime + + +class MemoryItemCreate(BaseModel): + content: str = Field(min_length=1) + + +class MemoryItemUpdate(BaseModel): + content: str = Field(min_length=1) + + +class MemoryDoc(BaseModel): + """The whole file-backend memory document (``MEMORY.md``). + + Only meaningful for ``memory_backend="file"``, where memory IS one markdown + file the agent reads: the UI previews and edits it as a document instead of + line-by-line items. The vector backend has no such file. + """ + + project_id: str + content: str = "" + updated_at: datetime + + +class MemoryDocUpdate(BaseModel): + # Empty is allowed here (unlike an item): clearing the document is how the + # user wipes file-backend memory. + content: str = "" + + +# ── vector backend health surface ────────────────────────────────────────── + + +class MemoryEmbedderInfo(BaseModel): + """Which embedding model this project's vector store runs on.""" + + mode: str # "provider" | "local" + provider: str | None = None # None for local + model: str | None = None + dimension: int | None = None # None until first probed + # Set when the default resolution had to fall back (e.g. the conversation + # provider serves no embeddings) — the UI shows this verbatim. + fallback_reason: str | None = None + + +class MemoryErrorInfo(BaseModel): + """Why vector memory is unusable right now. ``code`` is machine-readable + (embedder_mismatch | embed_unavailable | local_missing) so the UI can + offer the right remedy (e.g. a rebuild button for a mismatch).""" + + code: str + message: str + + +class MemoryIngestInfo(BaseModel): + """Last background-ingest outcome of the live runtime, if any.""" + + state: str # idle | scheduled | running | ok | error + at: str | None = None + count: int | None = None + error: str | None = None + pending: int = 0 + + +class MemoryStatus(BaseModel): + project_id: str + backend: str # "file" | "vector" + embedder: MemoryEmbedderInfo | None = None + error: MemoryErrorInfo | None = None + ingest: MemoryIngestInfo | None = None + # Whether the optional fastembed extra is installed (drives UI hints). + local_embed_available: bool = False diff --git a/webui/backend/app/schemas/model.py b/webui/backend/app/schemas/model.py new file mode 100644 index 000000000..97ff130ba --- /dev/null +++ b/webui/backend/app/schemas/model.py @@ -0,0 +1,25 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class Model(BaseModel): + id: str + provider_id: str + name: str + display_name: str = "" + is_builtin: bool = False + advanced_params: dict = Field(default_factory=dict) + created_at: datetime + + +class ModelCreate(BaseModel): + provider_id: str + name: str = Field(min_length=1, max_length=160) + display_name: str = "" + advanced_params: dict = Field(default_factory=dict) + + +class ModelUpdate(BaseModel): + display_name: str | None = None + advanced_params: dict | None = None diff --git a/webui/backend/app/schemas/profile.py b/webui/backend/app/schemas/profile.py new file mode 100644 index 000000000..30c8fc5f6 --- /dev/null +++ b/webui/backend/app/schemas/profile.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class Profile(BaseModel): + agent_calls_user: str = "User" + description: str = "" + updated_at: datetime + + +class ProfileUpsert(BaseModel): + agent_calls_user: str | None = Field(default=None, max_length=80) + description: str | None = None diff --git a/webui/backend/app/schemas/project.py b/webui/backend/app/schemas/project.py new file mode 100644 index 000000000..593b2de27 --- /dev/null +++ b/webui/backend/app/schemas/project.py @@ -0,0 +1,93 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +MemoryBackend = Literal["file", "vector"] +# Embedding source for a vector project: an OpenAI-compatible provider or the +# local fastembed model. +MemoryEmbedMode = Literal["provider", "local"] + +# Project-level tool authorization: "restricted" = every non-whitelisted tool +# asks (default), "auto" = full access, no asks (SafetyGuard still applies). +PermissionMode = Literal["restricted", "auto"] + + +class Project(BaseModel): + id: str + name: str + description: str = "" + local_path: str = "" + is_default: bool = False + memory_enabled: bool = True + memory_backend: MemoryBackend = "file" + # True once memory has been saved as ENABLED at least once. From then on the + # backend choice is frozen: it decides the on-disk storage layout, so + # switching it would orphan whatever is already stored. Toggling + # ``memory_enabled`` off (and on again) stays allowed — only the backend is + # locked. Surfaced so the UI can disable the selector and explain why. + memory_backend_locked: bool = False + # Vector-memory model choices, owned by the PROJECT (materialized from the + # global defaults at creation; global changes never touch existing + # projects). None provider/model = follow the conversation model. + memory_llm_provider_id: str | None = None + memory_llm_model: str | None = None + memory_embed_mode: MemoryEmbedMode = "provider" + memory_embed_provider_id: str | None = None + memory_embed_model: str | None = None + # Recalled memories injected per turn (vector backend). None = default 10. + memory_recall_top_k: int | None = None + mcp_auto_attach: bool = True + skill_auto_attach: bool = True + permission_mode: PermissionMode = "restricted" + created_at: datetime + + +class ProjectCreate(BaseModel): + name: str = Field(min_length=1, max_length=80) + description: str = "" + local_path: str = "" + # Optional — when omitted, inherits from AgentSettings.default_memory_*. + memory_enabled: bool | None = None + memory_backend: MemoryBackend | None = None + # Memory-model group: when NONE of these is sent, the global defaults are + # materialized instead (`model_fields_set` decides sent-ness). + memory_llm_provider_id: str | None = None + memory_llm_model: str | None = None + memory_embed_mode: MemoryEmbedMode | None = None + memory_embed_provider_id: str | None = None + memory_embed_model: str | None = None + memory_recall_top_k: int | None = None + + +class ProjectUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=80) + description: str | None = None + local_path: str | None = None + memory_enabled: bool | None = None + # Only accepted while the project has never had memory enabled (see + # ``Project.memory_backend_locked``); rejected with 400 afterwards. + memory_backend: MemoryBackend | None = None + # Sent as a whole group (the modal owns the section): if ANY of the five is + # present the stored group is replaced from the body. + memory_llm_provider_id: str | None = None + memory_llm_model: str | None = None + memory_embed_mode: MemoryEmbedMode | None = None + memory_embed_provider_id: str | None = None + memory_embed_model: str | None = None + memory_recall_top_k: int | None = None + mcp_auto_attach: bool | None = None + skill_auto_attach: bool | None = None + permission_mode: PermissionMode | None = None + + +# The five per-project memory-model fields, shared by create/update handling. +MEMORY_MODEL_FIELDS = ( + "memory_llm_provider_id", + "memory_llm_model", + "memory_embed_mode", + "memory_embed_provider_id", + "memory_embed_model", + "memory_recall_top_k", +) diff --git a/webui/backend/app/schemas/provider.py b/webui/backend/app/schemas/provider.py new file mode 100644 index 000000000..4305da567 --- /dev/null +++ b/webui/backend/app/schemas/provider.py @@ -0,0 +1,43 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +ProviderKind = Literal["builtin", "custom"] +Protocol = Literal["openai", "anthropic"] + + +class Provider(BaseModel): + id: str + kind: ProviderKind + name: str + base_url: str = "" + api_key_masked: str = "" + protocol: Protocol = "openai" + enabled: bool = True + default_generation_params: dict = Field(default_factory=dict) + # Read-only: the generation params the backend applies by default for this + # provider (currently the protocol-derived ``extra_body.enable_thinking``), + # so the settings UI can surface the thinking default in the params JSON + # even before the user sets anything. A model's advanced_params can override. + generation_defaults: dict = Field(default_factory=dict) + created_at: datetime + + +class ProviderCreate(BaseModel): + # custom providers only — builtin ones are seeded + id: str = Field(pattern=r"^[a-z0-9][a-z0-9_-]{0,40}$") + name: str = Field(min_length=1, max_length=80) + base_url: str = "" + protocol: Protocol = "openai" + default_generation_params: dict = Field(default_factory=dict) + + +class ProviderUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=80) + base_url: str | None = None + api_key: str | None = None # plain, server masks on read + protocol: Protocol | None = None + enabled: bool | None = None + default_generation_params: dict | None = None diff --git a/webui/backend/app/schemas/session.py b/webui/backend/app/schemas/session.py new file mode 100644 index 000000000..e1f430f0b --- /dev/null +++ b/webui/backend/app/schemas/session.py @@ -0,0 +1,142 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class Session(BaseModel): + id: str + title: str + project_id: str | None = None + updated_at: datetime + preview: str = "" + # True while this session has a turn in flight (live or continuing in the + # background after its viewer navigated away) — drives the sidebar spinner. + running: bool = False + # Agent-assigned topic category (see ms_agent/titler.CATEGORIES); "" when the + # session hasn't been classified yet. Drives the recent-list topic icon. + category: str = "" + + +class SessionCreate(BaseModel): + title: str = Field(min_length=1, max_length=160) + project_id: str | None = None + preview: str = "" + + +class SessionUpdate(BaseModel): + title: str | None = None + + +class SessionStep(BaseModel): + """A reconstructed step card (mirrors agentProvider.ts::AgentStep).""" + + kind: str + meta: dict = Field(default_factory=dict) + + +class SessionTask(BaseModel): + """A reconstructed plan (todo) item — label + status. Execution steps are no + longer nested here; they are their own ``step`` parts (agentProvider.ts).""" + + id: str + label: str + status: str = "done" + + +class SessionPlan(BaseModel): + """The session's live plan file plus whether it belongs to the CURRENT + running turn. ``active`` is the server-side truth for "a task is actually + being worked on right now": the turn is in flight AND plan.json was + (re)written during it — a stale in_progress row from an earlier turn stays + inactive, and a rejoining client (reload or tab switch-back) gets the same + answer without having to observe the stream first.""" + + tasks: list[SessionTask] = [] + active: bool = False + + +class SessionPart(BaseModel): + """One ordered block of a reconstructed assistant turn (mirrors + agentProvider.ts::AgentPart). ``kind`` routes the fields used: + + - text: ``text`` holds a markdown answer block + - thought: ``text`` holds a persisted reasoning block; ``duration`` is its + elapsed seconds (persisted, so replay shows "thought Ns") + - tasks: ``tasks`` holds the plan items (labels + status) as plain text + - step: ``step`` holds one tool-call card, rendered inline in stream + order (a tool step carries ``meta.duration_ms``; an authorization + step carries ``meta.state`` = approved|rejected for replay) + - error: ``text`` holds an API/turn error message; ``recoverable`` says + whether it re-entered model context (turn/API errors do not) + + A failed tool step carries ``status="error"`` (+ ``error``) in its meta. + """ + + kind: str + text: str = "" + duration: int | None = None + tasks: list[SessionTask] = Field(default_factory=list) + step: SessionStep | None = None + recoverable: bool = True + + +class SessionFile(BaseModel): + """A file the user attached to a turn, reconstructed on history replay from + the persisted paths. ``exists`` is False when the workspace file has since + been deleted, so the frontend can show a generic card + "deleted" note.""" + + name: str + path: str # workspace-relative, e.g. "user_files/report.pdf" + url: str | None = None # raw byte URL (frontend preview only) + type: str | None = None # 'file' | 'image' | 'audio' | 'video' + size: int | None = None # bytes (None when the file no longer exists) + exists: bool = True + + +class SessionMessage(BaseModel): + role: str + content: str + # Ordered turn view-model rebuilt from the session log so history echoes the + # same interleaving of answer text and the task/step timeline as a live turn. + # ``content`` stays populated (joined text) for plain/fallback consumers. + parts: list[SessionPart] = Field(default_factory=list) + # Files the user attached to this turn (user messages only), reconstructed + # from the persisted ``[Attached files]`` block so replay shows file cards. + files: list[SessionFile] = Field(default_factory=list) + # Workspace paths the agent wrote/edited during THIS turn's tool-call loop + # (assistant messages only), so the frontend can collapse the intermediate + # steps into a "done" summary listing what changed — the history-replay + # counterpart of the live ``done`` frame's ``changed_files`` meta. + changed_files: list[str] = Field(default_factory=list) + # Wall-clock duration of the turn's tool-call loop, from the persisted + # ``loop_end`` marker (not derivable from message rows). None for turns + # predating the marker; the frontend then omits the "done · Ns" timing. + duration_ms: int | None = None + # Absolute path of the session plan markdown when THIS turn rewrote the + # todo list (loop_end marker's ``plan_file``; pairs with the "plan.md" + # entry in ``changed_files``). The plan lives in the SESSION dir, not the + # workspace — render its chip via the plan components with content from + # ``GET /sessions/{id}/plan``, never via a workspace-exists check. + plan_file: str | None = None + # Configuration-style content echo (user messages only): the same segment + # array shape the composer sends (``[{type: text|skill, ...}]``), rebuilt + # from the skill-invocation marker so the frontend re-renders skill pills. + segments: list[dict] = Field(default_factory=list) + + +class Artifact(BaseModel): + id: str + session_id: str + path: str + # Basename for display; ``kind`` stays "file" (the frontend derives an icon + # from the extension/name). + name: str = "" + kind: str = "file" + size: int = 0 + updated_at: datetime + # True when the agent wrote/edited this file during the session but it no + # longer exists on disk (user deleted it, or a later step removed it). The + # ledger keeps the entry — history is not erased by later user actions — and + # the frontend renders it greyed ("deleted"). + deleted: bool = False + preview: str | None = None diff --git a/webui/backend/app/schemas/skill.py b/webui/backend/app/schemas/skill.py new file mode 100644 index 000000000..d2ac405b8 --- /dev/null +++ b/webui/backend/app/schemas/skill.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class Skill(BaseModel): + id: str + name: str + kind: str = "file-type" + content: str = "" + enabled: bool = True + scope: str + created_at: datetime + + +class SkillCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + kind: str = "file-type" + content: str = "" + enabled: bool = True + scope: str + + +class SkillUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + kind: str | None = None + content: str | None = None + enabled: bool | None = None + + +class SkillFile(BaseModel): + """One file inside a skill's on-disk directory (relative path).""" + + path: str + size: int | None = None + + +class SkillFileContent(BaseModel): + path: str + # None ⇒ the file is binary / not valid UTF-8 (frontend shows a notice). + content: str | None = None diff --git a/webui/backend/app/schemas/workspace.py b/webui/backend/app/schemas/workspace.py new file mode 100644 index 000000000..1b6edc0d5 --- /dev/null +++ b/webui/backend/app/schemas/workspace.py @@ -0,0 +1,39 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class WorkspaceFile(BaseModel): + project_id: str + path: str + kind: str = "file" + size: int = 0 + updated_at: datetime + preview: str | None = None + # Full text content, only populated on single-file GET (never in listings). + # None for folders, binary/undecodable files, or when omitted for size. + content: str | None = None + # Best-effort MIME type guessed from the extension. Drives the frontend + # preview: text -> Monaco, image/video/audio -> media element, else -> a + # "preview unavailable" placeholder. Raw bytes are served from .../raw. + content_type: str | None = None + + +class WorkspaceFileCreate(BaseModel): + path: str = Field(min_length=1, max_length=400) + content: str = "" + kind: str = "file" + size: int | None = None # If provided, use this instead of computing from content + + +class WorkspaceFileUpdate(BaseModel): + content: str = "" + + +class WorkspaceFileMove(BaseModel): + """Rename or move a file/folder: ``src`` and ``dst`` are workspace-relative + paths. A rename keeps the parent dir; a move changes it. Folders move with + all their children.""" + + src: str = Field(min_length=1, max_length=400) + dst: str = Field(min_length=1, max_length=400) diff --git a/webui/backend/config_manager.py b/webui/backend/config_manager.py deleted file mode 100644 index 8d9c7f623..000000000 --- a/webui/backend/config_manager.py +++ /dev/null @@ -1,254 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Configuration management for MS-Agent Web UI -Handles global settings, LLM configuration, and MCP server configuration. -""" -import json -import os -from threading import Lock -from typing import Any, Dict, Optional - - -class ConfigManager: - """Manages global configuration for the Web UI""" - - DEFAULT_CONFIG = { - 'llm': { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen3-235B-A22B-Instruct-2507', - 'api_key': '', - 'base_url': 'https://api-inference.modelscope.cn/v1/', - 'temperature': None, - 'temperature_enabled': False, - 'max_tokens': None - }, - 'deep_research': { - 'researcher': { - 'model': '', - 'api_key': '', - 'base_url': '' - }, - 'searcher': { - 'model': '', - 'api_key': '', - 'base_url': '' - }, - 'reporter': { - 'model': '', - 'api_key': '', - 'base_url': '' - }, - 'search': { - 'summarizer_model': '', - 'summarizer_api_key': '', - 'summarizer_base_url': '' - } - }, - 'edit_file_config': { - 'api_key': '', - 'base_url': 'https://api.morphllm.com/v1', - 'diff_model': 'morph-v3-fast' - }, - 'edgeone_pages': { - 'api_token': '', - 'project_name': '' - }, - 'search_keys': { - 'exa_api_key': '', - 'serpapi_api_key': '', - }, - 'mcp_servers': {}, - 'theme': 'dark', - 'output_dir': './output' - } - - def __init__(self, config_dir: str): - # Expand user path to handle ~ notation - self.config_dir = os.path.expanduser(config_dir) - self.config_file = os.path.join(self.config_dir, 'settings.json') - self.mcp_file = os.path.join(self.config_dir, 'mcp_servers.json') - self._lock = Lock() - self._config: Optional[Dict[str, Any]] = None - self._ensure_config_dir() - - def _ensure_config_dir(self): - """Ensure config directory exists""" - os.makedirs(self.config_dir, exist_ok=True) - - def _load_config(self) -> Dict[str, Any]: - """Load configuration from file""" - if self._config is not None: - return self._config - - if os.path.exists(self.config_file): - try: - with open(self.config_file, 'r', encoding='utf-8') as f: - self._config = json.load(f) - except Exception: - self._config = self.DEFAULT_CONFIG.copy() - else: - self._config = self.DEFAULT_CONFIG.copy() - - # Load MCP servers from separate file if exists - if os.path.exists(self.mcp_file): - try: - with open(self.mcp_file, 'r', encoding='utf-8') as f: - mcp_data = json.load(f) - if 'mcpServers' in mcp_data: - self._config['mcp_servers'] = mcp_data['mcpServers'] - else: - self._config['mcp_servers'] = mcp_data - except Exception: - pass - - return self._config - - def _save_config(self): - """Save configuration to file""" - with self._lock: - # Save main config (without mcp_servers) - config_to_save = { - k: v - for k, v in self._config.items() if k != 'mcp_servers' - } - with open(self.config_file, 'w', encoding='utf-8') as f: - json.dump(config_to_save, f, indent=2) - - # Save MCP servers to separate file (compatible with ms-agent format) - mcp_data = {'mcpServers': self._config.get('mcp_servers', {})} - with open(self.mcp_file, 'w', encoding='utf-8') as f: - json.dump(mcp_data, f, indent=2) - - def get_config(self) -> Dict[str, Any]: - """Get the full configuration""" - return self._load_config().copy() - - def update_config(self, config: Dict[str, Any]): - """Update the full configuration""" - self._load_config() - self._config.update(config) - self._save_config() - - def get_llm_config(self) -> Dict[str, Any]: - """Get LLM configuration""" - config = self._load_config() - return config.get('llm', self.DEFAULT_CONFIG['llm']) - - def update_llm_config(self, llm_config: Dict[str, Any]): - """Update LLM configuration""" - self._load_config() - self._config['llm'] = llm_config - self._save_config() - - def get_mcp_config(self) -> Dict[str, Any]: - """Get MCP servers configuration""" - config = self._load_config() - return {'mcpServers': config.get('mcp_servers', {})} - - def update_mcp_config(self, mcp_config: Dict[str, Any]): - """Update MCP servers configuration""" - self._load_config() - if 'mcpServers' in mcp_config: - self._config['mcp_servers'] = mcp_config['mcpServers'] - else: - self._config['mcp_servers'] = mcp_config - self._save_config() - - def get_edit_file_config(self) -> Dict[str, Any]: - """Get edit_file_config configuration""" - config = self._load_config() - return config.get('edit_file_config', - self.DEFAULT_CONFIG['edit_file_config']) - - def update_edit_file_config(self, edit_file_config: Dict[str, Any]): - """Update edit_file_config configuration""" - self._load_config() - self._config['edit_file_config'] = edit_file_config - self._save_config() - - def get_edgeone_pages_config(self) -> Dict[str, Any]: - """Get EdgeOne Pages configuration""" - config = self._load_config() - return config.get('edgeone_pages', - self.DEFAULT_CONFIG['edgeone_pages']) - - def update_edgeone_pages_config(self, edgeone_pages_config: Dict[str, - Any]): - """Update EdgeOne Pages configuration""" - self._load_config() - self._config['edgeone_pages'] = edgeone_pages_config - self._save_config() - - def get_search_keys(self) -> Dict[str, Any]: - """Get search API keys configuration""" - config = self._load_config() - return config.get('search_keys', self.DEFAULT_CONFIG['search_keys']) - - def update_search_keys(self, search_keys: Dict[str, Any]): - """Update search API keys configuration""" - self._load_config() - self._config['search_keys'] = search_keys - self._save_config() - - def get_deep_research_config(self) -> Dict[str, Any]: - """Get deep research configuration""" - config = self._load_config() - return config.get('deep_research', - self.DEFAULT_CONFIG['deep_research']) - - def update_deep_research_config(self, deep_research_config: Dict[str, - Any]): - """Update deep research configuration""" - self._load_config() - self._config['deep_research'] = deep_research_config - self._save_config() - - def add_mcp_server(self, name: str, server_config: Dict[str, Any]): - """Add a new MCP server""" - self._load_config() - if 'mcp_servers' not in self._config: - self._config['mcp_servers'] = {} - self._config['mcp_servers'][name] = server_config - self._save_config() - - def remove_mcp_server(self, name: str) -> bool: - """Remove an MCP server""" - self._load_config() - if name in self._config.get('mcp_servers', {}): - del self._config['mcp_servers'][name] - self._save_config() - return True - return False - - def get_mcp_file_path(self) -> str: - """Get the path to the MCP servers file""" - return self.mcp_file - - def get_env_vars(self) -> Dict[str, str]: - """Get environment variables for running agents""" - config = self._load_config() - llm = config.get('llm', {}) - search_keys = config.get('search_keys', {}) - - env_vars = {} - - if llm.get('api_key'): - provider = llm.get('provider', 'modelscope') - if provider == 'modelscope': - env_vars['MODELSCOPE_API_KEY'] = llm['api_key'] - elif provider == 'openai': - env_vars['OPENAI_API_KEY'] = llm['api_key'] - elif provider == 'anthropic': - env_vars['ANTHROPIC_API_KEY'] = llm['api_key'] - - if llm.get('base_url'): - env_vars['OPENAI_BASE_URL'] = llm['base_url'] - - exa_key = search_keys.get('exa_api_key') - if exa_key: - env_vars['EXA_API_KEY'] = exa_key - serp_key = search_keys.get('serpapi_api_key') - if serp_key: - env_vars['SERPAPI_API_KEY'] = serp_key - - return env_vars diff --git a/webui/backend/deep_research_eventizer.py b/webui/backend/deep_research_eventizer.py deleted file mode 100644 index f1d5d6d74..000000000 --- a/webui/backend/deep_research_eventizer.py +++ /dev/null @@ -1,433 +0,0 @@ -import json -from typing import Any, Callable, Dict, List, Optional - -from ms_agent.llm.utils import Message, ToolCall - - -def _stringify_content(content: Any) -> str: - if content is None: - return '' - if isinstance(content, str): - return content - try: - return json.dumps(content, ensure_ascii=False) - except Exception: - return str(content) - - -class HistoryEventizer: - - def __init__( - self, - emit: Callable[[Dict[str, Any]], None], - *, - channel: str = 'main', - session_id: Optional[str] = None, - turn_id: Optional[str] = None, - card_id: Optional[str] = None, - ) -> None: - self._emit = emit - self._channel = channel - self._session_id = session_id - self._turn_id = turn_id - self._card_id = card_id - self._prev_messages: List[Message] = [] - self._message_ids: List[str] = [] - self._assistant_contents: Dict[str, str] = {} - self._completed_messages: set[str] = set() - self._seen_tool_calls: set[str] = set() - self._seen_tool_results: set[str] = set() - self._subagent_call_ids: set[str] = set() - self._tool_call_args: Dict[str, Dict[str, Any]] = {} - self._tool_call_names: Dict[str, str] = {} - - def reset(self) -> None: - self._prev_messages = [] - self._message_ids = [] - self._assistant_contents = {} - self._completed_messages = set() - self._seen_tool_calls = set() - self._seen_tool_results = set() - self._subagent_call_ids = set() - self._tool_call_args = {} - self._tool_call_names = {} - - def _wrap_event(self, event_type: str, - payload: Dict[str, Any]) -> Dict[str, Any]: - event: Dict[str, Any] = {'type': event_type, 'payload': payload} - if self._session_id: - event['session_id'] = self._session_id - if self._turn_id: - event['turn_id'] = self._turn_id - return event - - def _emit_event(self, event_type: str, payload: Dict[str, Any]) -> None: - self._emit(self._wrap_event(event_type, payload)) - - def _should_reset(self, messages: List[Message]) -> bool: - if len(messages) < len(self._prev_messages): - return True - for idx, msg in enumerate(messages[:len(self._prev_messages)]): - if msg.role != self._prev_messages[idx].role: - return True - return False - - def _ensure_message_id(self, idx: int, message: Message) -> str: - if idx < len(self._message_ids): - return self._message_ids[idx] - raw_id = getattr(message, 'id', None) - if raw_id: - msg_id = raw_id - else: - prefix = self._card_id or self._channel - msg_id = f'{prefix}-{idx}' - self._message_ids.append(msg_id) - return msg_id - - def _is_subagent_tool(self, tool_name: str) -> bool: - if not tool_name: - return False - return tool_name.startswith('agent_tools---') or tool_name.endswith( - 'searcher_tool') or tool_name.endswith('reporter_tool') - - def _extract_tool_name(self, call: ToolCall) -> str: - if not isinstance(call, dict): - return '' - tool_name = call.get('tool_name') - if tool_name: - return tool_name - func = call.get('function') or {} - if isinstance(func, dict): - return func.get('name', '') or '' - return '' - - def _extract_tool_args_raw(self, call: ToolCall) -> Any: - if not isinstance(call, dict): - return {} - if 'arguments' in call: - return call.get('arguments') - func = call.get('function') or {} - if isinstance(func, dict): - return func.get('arguments', {}) - return {} - - def _parse_tool_args(self, raw: Any) -> Dict[str, Any]: - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - return json.loads(raw) - except Exception: - return {'request': raw} - if isinstance(raw, list): - return {'messages': raw} - if raw is not None: - return {'request': raw} - return {} - - def _build_subagent_title(self, tool_name: str, - tool_args: Dict[str, Any]) -> str: - if 'searcher' in tool_name: - base = 'Searcher' - elif 'reporter' in tool_name: - base = 'Reporter' - else: - base = tool_name.split('---')[-1] or tool_name - - request = tool_args.get('request') - summary = None - if isinstance(request, str) and request.strip(): - try: - parsed = json.loads(request) - if isinstance(parsed, dict): - summary = parsed.get('task_id') or parsed.get( - '调研目标') or parsed.get('目标') - except Exception: - summary = None - if summary is None: - summary = request.strip().splitlines()[0][:80] - return f'{base}: {summary}' if summary else base - - def _record_tool_call(self, call_id: str, tool_name: str, - tool_args: Dict[str, Any]) -> tuple[bool, bool]: - is_new = call_id not in self._seen_tool_calls - prev_args = self._tool_call_args.get(call_id) - prev_name = self._tool_call_names.get(call_id) - if (not is_new and prev_args == tool_args - and (not tool_name or tool_name == prev_name)): - return False, False - self._seen_tool_calls.add(call_id) - self._tool_call_args[call_id] = tool_args - if tool_name: - self._tool_call_names[call_id] = tool_name - return True, is_new - - def _maybe_emit_todos(self, tool_name: str, result_text: str, - call_id: Optional[str]) -> None: - if not tool_name: - return - if not ('todo_list---todo_write' in tool_name - or 'todo_list---todo_read' in tool_name): - return - try: - parsed = json.loads(result_text) - except Exception: - return - todos = None - if isinstance(parsed, dict): - todos = parsed.get('todos') - elif isinstance(parsed, list): - todos = parsed - if isinstance(todos, list): - payload: Dict[str, Any] = {'todos': todos} - if call_id: - payload['call_id'] = call_id - self._emit_event('dr.state', payload) - - def _emit_assistant_delta(self, message_id: str, delta: str, - full: str) -> None: - payload = { - 'message_id': message_id, - 'delta': delta, - 'full': full, - } - self._emit_event('dr.chat.message.delta', payload) - - def _emit_subagent_delta(self, message_id: str, delta: str, - full: str) -> None: - payload = { - 'card_id': self._card_id, - 'message_id': message_id, - 'delta': delta, - 'full': full, - } - self._emit_event('dr.subagent.message.delta', payload) - - def _emit_subagent_message(self, message_id: str, role: str, - content: str) -> None: - payload = { - 'card_id': self._card_id, - 'message_id': message_id, - 'role': role, - 'content': content, - } - self._emit_event('dr.subagent.message', payload) - - def _emit_assistant_completed(self, message_id: str, content: str) -> None: - payload = { - 'message_id': message_id, - 'role': 'assistant', - 'content': content, - } - self._emit_event('dr.chat.message.completed', payload) - - def _emit_chat_message(self, message_id: str, role: str, content: str, - name: Optional[str]) -> None: - payload = { - 'message_id': message_id, - 'role': role, - 'content': content, - } - if name: - payload['name'] = name - self._emit_event('dr.chat.message', payload) - - def _process_tool_calls(self, message_id: str, - tool_calls: List[ToolCall]) -> None: - for idx, call in enumerate(tool_calls or []): - call_id = call.get('id') or f'{message_id}-call-{idx}' - tool_name = self._extract_tool_name(call) - tool_args = self._parse_tool_args( - self._extract_tool_args_raw(call)) - should_emit, is_new = self._record_tool_call( - call_id, tool_name, tool_args) - if not should_emit: - continue - category = 'subagent' if self._is_subagent_tool( - tool_name) else 'normal' - payload = { - 'call_id': call_id, - 'source_message_id': message_id, - 'tool': { - 'name': tool_name, - 'arguments': tool_args, - }, - 'category': category, - } - if not is_new: - payload['updated'] = True - self._emit_event('dr.tool.call', payload) - if category == 'subagent' and call_id not in self._subagent_call_ids: - self._subagent_call_ids.add(call_id) - card_payload = { - 'card_id': call_id, - 'tool_name': tool_name, - 'title': self._build_subagent_title(tool_name, tool_args), - 'source_message_id': message_id, - } - self._emit_event('dr.subagent.card.start', card_payload) - - def _process_tool_result(self, message: Message) -> None: - call_id = message.tool_call_id - if not call_id or call_id in self._seen_tool_results: - return - self._seen_tool_results.add(call_id) - tool_name = message.name or '' - result_text = _stringify_content(message.content) - payload: Dict[str, Any] = { - 'call_id': call_id, - 'tool_name': tool_name, - 'result_text': result_text, - 'is_error': False, - } - tool_args = self._tool_call_args.get(call_id) - if tool_args is not None: - payload['tool'] = { - 'name': tool_name or self._tool_call_names.get(call_id, ''), - 'arguments': tool_args, - } - self._emit_event('dr.tool.result', payload) - if call_id in self._subagent_call_ids: - summary = result_text.strip().splitlines()[0][:160] - self._emit_event('dr.subagent.card.completed', { - 'card_id': call_id, - 'summary': summary, - }) - self._maybe_emit_todos(tool_name, result_text, call_id) - - def process(self, messages: List[Message]) -> None: - if not messages: - return - if self._should_reset(messages): - self.reset() - - prev_len = len(self._prev_messages) - for idx, message in enumerate(messages): - message_id = self._ensure_message_id(idx, message) - role = message.role - - if self._channel == 'main': - if role == 'assistant': - content = _stringify_content(message.content) - prev_content = self._assistant_contents.get(message_id, '') - if content and content != prev_content: - if content.startswith(prev_content): - delta = content[len(prev_content):] - else: - delta = content - if delta: - self._emit_assistant_delta(message_id, delta, - content) - self._assistant_contents[message_id] = content - if message.tool_calls: - self._process_tool_calls(message_id, - message.tool_calls) - elif role == 'tool': - self._process_tool_result(message) - else: - if role == 'system': - continue - if idx >= prev_len: - content = _stringify_content(message.content) - if content: - self._emit_chat_message( - message_id, role, content, - getattr(message, 'name', None)) - else: - if role == 'assistant': - content = _stringify_content(message.content) - prev_content = self._assistant_contents.get(message_id, '') - if content and content != prev_content: - if content.startswith(prev_content): - delta = content[len(prev_content):] - else: - delta = content - if delta: - self._emit_subagent_delta(message_id, delta, - content) - self._assistant_contents[message_id] = content - if message.tool_calls: - for idx, call in enumerate(message.tool_calls or []): - call_id = call.get( - 'id') or f'{message_id}-call-{idx}' - tool_name = self._extract_tool_name(call) - tool_args = self._parse_tool_args( - self._extract_tool_args_raw(call)) - should_emit, is_new = self._record_tool_call( - call_id, tool_name, tool_args) - if not should_emit: - continue - payload = { - 'card_id': self._card_id, - 'call_id': call_id, - 'source_message_id': message_id, - 'tool': { - 'name': tool_name, - 'arguments': tool_args, - }, - } - if not is_new: - payload['updated'] = True - self._emit_event('dr.subagent.tool.call', payload) - else: - if role == 'system': - continue - if role != 'tool' and idx >= prev_len: - content = _stringify_content(message.content) - if content: - self._emit_subagent_message( - message_id, role, content) - if role == 'tool' and message.tool_call_id: - call_id = message.tool_call_id - if call_id in self._seen_tool_results: - continue - self._seen_tool_results.add(call_id) - tool_name = message.name or '' - payload: Dict[str, Any] = { - 'card_id': self._card_id, - 'call_id': call_id, - 'tool_name': tool_name, - 'result_text': _stringify_content(message.content), - } - tool_args = self._tool_call_args.get(call_id) - if tool_args is not None: - payload['tool'] = { - 'name': - (tool_name - or self._tool_call_names.get(call_id, '')), - 'arguments': - tool_args, - } - self._emit_event('dr.subagent.tool.result', payload) - - if self._channel == 'main': - last_idx = len(messages) - 1 - for idx, message in enumerate(messages): - if message.role != 'assistant': - continue - if idx >= last_idx: - continue - msg_id = self._message_ids[idx] - if msg_id in self._completed_messages: - continue - content = self._assistant_contents.get(msg_id, '') - self._emit_assistant_completed(msg_id, content) - self._completed_messages.add(msg_id) - - self._prev_messages = list(messages) - - def finalize(self) -> None: - if self._channel != 'main': - return - if not self._prev_messages: - return - last_idx = len(self._prev_messages) - 1 - last_msg = self._prev_messages[last_idx] - if last_msg.role != 'assistant': - return - msg_id = self._message_ids[last_idx] - if msg_id in self._completed_messages: - return - content = self._assistant_contents.get(msg_id, '') - self._emit_assistant_completed(msg_id, content) - self._completed_messages.add(msg_id) diff --git a/webui/backend/deep_research_worker.py b/webui/backend/deep_research_worker.py deleted file mode 100644 index 5a0ce2d80..000000000 --- a/webui/backend/deep_research_worker.py +++ /dev/null @@ -1,378 +0,0 @@ -import argparse -import asyncio -import json -import os -import signal -import sys -import traceback -from deep_research_eventizer import HistoryEventizer # noqa: E402 -from omegaconf import OmegaConf -from pathlib import Path -from typing import Any, Dict, Optional - -from ms_agent.agent.loader import AgentLoader -from ms_agent.tools.agent_tool import AgentTool - -BACKEND_DIR = Path(__file__).resolve().parent -if str(BACKEND_DIR) not in sys.path: - sys.path.insert(0, str(BACKEND_DIR)) - -STOP_REQUESTED = False - - -class NullWriter: - - def write(self, _: str) -> int: - return 0 - - def flush(self) -> None: - return None - - -class NDJSONEmitter: - - def __init__(self, stream) -> None: - self._stream = stream - - def emit(self, event: Dict[str, Any]) -> None: - try: - self._stream.write(json.dumps(event, ensure_ascii=False) + '\n') - self._stream.flush() - except Exception: - pass - - -def _load_llm_config() -> Dict[str, Any]: - raw = os.environ.get('MS_AGENT_LLM_CONFIG') - if not raw: - return {} - try: - return json.loads(raw) - except Exception: - return {} - - -def _load_deep_research_config() -> Dict[str, Any]: - raw = os.environ.get('MS_AGENT_DEEP_RESEARCH_CONFIG') - if not raw: - return {} - try: - return json.loads(raw) - except Exception: - return {} - - -def _normalize_agent_override(raw: Optional[Dict[str, Any]]) -> Dict[str, str]: - raw = raw or {} - return { - 'model': str(raw.get('model') or ''), - 'api_key': str(raw.get('api_key') or ''), - 'base_url': str(raw.get('base_url') or ''), - } - - -def _resolve_agent_llm_config(role: str, llm_config: Dict[str, Any], - dr_config: Dict[str, Any]) -> Dict[str, str]: - overrides = _normalize_agent_override((dr_config or {}).get(role)) - return { - 'model': - overrides.get('model') or str(llm_config.get('model') or ''), - 'api_key': - overrides.get('api_key') or str(llm_config.get('api_key') or ''), - 'base_url': - overrides.get('base_url') or str(llm_config.get('base_url') or ''), - } - - -def _normalize_search_override( - raw: Optional[Dict[str, Any]]) -> Dict[str, str]: - raw = raw or {} - return { - 'summarizer_model': str(raw.get('summarizer_model') or ''), - 'summarizer_api_key': str(raw.get('summarizer_api_key') or ''), - 'summarizer_base_url': str(raw.get('summarizer_base_url') or ''), - } - - -def _build_config_override( - llm_config: Dict[str, Any], output_dir: str, - dr_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: - override: Dict[str, Any] = {} - if output_dir: - override['output_dir'] = output_dir - - llm_override: Dict[str, Any] = {} - resolved = _resolve_agent_llm_config('researcher', llm_config, dr_config) - model = resolved.get('model') - api_key = resolved.get('api_key') - base_url = resolved.get('base_url') - temperature = llm_config.get('temperature') - temperature_enabled = bool(llm_config.get('temperature_enabled', False)) - max_tokens = llm_config.get('max_tokens') - - if model: - llm_override['model'] = model - - if api_key: - llm_override['openai_api_key'] = api_key - if base_url: - llm_override['openai_base_url'] = base_url - - if llm_override: - override['llm'] = llm_override - - gen_override: Dict[str, Any] = {} - if temperature_enabled and temperature is not None: - gen_override['temperature'] = temperature - if max_tokens: - gen_override['max_tokens'] = max_tokens - if gen_override: - override['generation_config'] = gen_override - - return override or None - - -async def _watch_artifacts(output_dir: str, emitter: NDJSONEmitter, - session_id: str) -> None: - last_snapshot: Dict[str, tuple[int, float]] = {} - output_path = Path(output_dir) - ignore_dirs = {'.locks', '__pycache__'} - - while True: - snapshot: Dict[str, tuple[int, float]] = {} - files = [] - if output_path.exists(): - for path in output_path.rglob('*'): - if path.is_dir(): - if path.name in ignore_dirs: - continue - continue - rel_path = path.relative_to(output_path).as_posix() - if rel_path.startswith('.locks/'): - continue - try: - stat = path.stat() - except OSError: - continue - snapshot[rel_path] = (stat.st_size, stat.st_mtime) - files.append({ - 'path': rel_path, - 'relative_path': rel_path, - 'size': stat.st_size, - 'modified': stat.st_mtime, - }) - - if snapshot != last_snapshot: - emitter.emit({ - 'type': 'dr.artifact.updated', - 'payload': { - 'files': - sorted( - files, - key=lambda x: x.get('modified', 0), - reverse=True) - }, - 'session_id': session_id, - }) - last_snapshot = snapshot - - await asyncio.sleep(1.0) - - -async def run_worker(args: argparse.Namespace) -> None: - emitter = NDJSONEmitter(sys.__stdout__) - main_eventizer = HistoryEventizer( - emitter.emit, channel='main', session_id=args.session_id) - subagent_eventizers: Dict[str, HistoryEventizer] = {} - - loop = asyncio.get_running_loop() - subagent_queue: asyncio.Queue = asyncio.Queue() - - def chunk_callback(*, event_type: str, data: Dict[str, Any]) -> None: - loop.call_soon_threadsafe(subagent_queue.put_nowait, - (event_type, data)) - - async def consume_subagent_events(): - while True: - event_type, data = await subagent_queue.get() - if event_type is None: - break - call_id = data.get('call_id') - if not call_id: - continue - eventizer = subagent_eventizers.get(call_id) - if not eventizer: - eventizer = HistoryEventizer( - emitter.emit, - channel='subagent', - session_id=args.session_id, - card_id=call_id, - ) - subagent_eventizers[call_id] = eventizer - history = data.get('history') - if isinstance(history, list): - eventizer.process(history) - - llm_config = _load_llm_config() - dr_config = _load_deep_research_config() - config_override = _build_config_override(llm_config, args.output_dir, - dr_config) - config_override = OmegaConf.create( - config_override) if config_override else None - - agent = AgentLoader.build( - config_dir_or_id=args.config, - config=config_override, - env=os.environ.copy(), - trust_remote_code=True, - load_cache=True, - ) - - original_prepare_tools = agent.prepare_tools - - async def prepare_tools_with_callback(): - await original_prepare_tools() - if getattr(agent, 'tool_manager', None) is None: - return - for tool in agent.tool_manager.extra_tools: - if isinstance(tool, AgentTool): - tool.set_chunk_callback(chunk_callback) - for spec in getattr(tool, '_specs', {}).values(): - inline_cfg = spec.inline_config or {} - if inline_cfg.get('output_dir') != args.output_dir: - updated = dict(inline_cfg) - updated['output_dir'] = args.output_dir - spec.inline_config = updated - - tool_name = str(spec.tool_name or '') - if 'searcher' in tool_name: - resolved = _resolve_agent_llm_config( - 'searcher', llm_config, dr_config) - search_override = _normalize_search_override( - (dr_config or {}).get('search')) - elif 'reporter' in tool_name: - resolved = _resolve_agent_llm_config( - 'reporter', llm_config, dr_config) - search_override = {} - else: - resolved = {} - search_override = {} - - if resolved: - updated = dict(spec.inline_config or {}) - llm_cfg = dict(updated.get('llm') or {}) - if resolved.get('model'): - llm_cfg['model'] = resolved['model'] - if resolved.get('api_key'): - llm_cfg['openai_api_key'] = resolved['api_key'] - if resolved.get('base_url'): - llm_cfg['openai_base_url'] = resolved['base_url'] - if llm_cfg: - updated['llm'] = llm_cfg - if search_override: - tools_cfg = dict(updated.get('tools') or {}) - web_cfg = dict(tools_cfg.get('web_search') or {}) - if search_override.get('summarizer_model'): - web_cfg['summarizer_model'] = search_override[ - 'summarizer_model'] - if search_override.get('summarizer_api_key'): - web_cfg[ - 'summarizer_api_key'] = search_override[ - 'summarizer_api_key'] - if search_override.get('summarizer_base_url'): - web_cfg[ - 'summarizer_base_url'] = search_override[ - 'summarizer_base_url'] - if web_cfg: - tools_cfg['web_search'] = web_cfg - updated['tools'] = tools_cfg - spec.inline_config = updated - - env_cfg = dict(spec.env or {}) - if resolved.get('api_key'): - env_cfg['OPENAI_API_KEY'] = resolved['api_key'] - if resolved.get('base_url'): - env_cfg['OPENAI_BASE_URL'] = resolved['base_url'] - spec.env = env_cfg - - agent.prepare_tools = prepare_tools_with_callback - - artifact_task = asyncio.create_task( - _watch_artifacts(args.output_dir, emitter, args.session_id)) - subagent_task = asyncio.create_task(consume_subagent_events()) - - had_error = False - try: - result = await agent.run(messages=args.query, stream=True) - if hasattr(result, '__aiter__'): - async for history in result: - if isinstance(history, list): - main_eventizer.process(history) - elif isinstance(result, list): - main_eventizer.process(result) - except Exception as exc: - had_error = True - emitter.emit({ - 'type': 'dr.worker.error', - 'payload': { - 'error': str(exc), - 'traceback': traceback.format_exc(), - }, - 'session_id': args.session_id, - }) - emitter.emit({ - 'type': 'error', - 'message': str(exc), - }) - raise - finally: - main_eventizer.finalize() - emitter.emit({ - 'type': 'dr.worker.exited', - 'payload': { - 'status': 'completed' - }, - 'session_id': args.session_id, - }) - if STOP_REQUESTED: - emitter.emit({ - 'type': 'status', - 'status': 'stopped', - }) - elif not had_error: - emitter.emit({ - 'type': 'complete', - 'result': { - 'status': 'success', - }, - }) - subagent_queue.put_nowait((None, None)) - artifact_task.cancel() - subagent_task.cancel() - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument('--config', required=True, help='Path to agent yaml') - parser.add_argument('--query', required=True, help='User query') - parser.add_argument('--session_id', required=True) - parser.add_argument('--output_dir', required=True) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - sys.argv = [sys.argv[0]] - sys.stdout = NullWriter() - - def _handle_stop(_sig, _frame): - global STOP_REQUESTED - STOP_REQUESTED = True - - signal.signal(signal.SIGTERM, _handle_stop) - signal.signal(signal.SIGINT, _handle_stop) - asyncio.run(run_worker(args)) - - -if __name__ == '__main__': - main() diff --git a/webui/backend/deep_research_worker_manager.py b/webui/backend/deep_research_worker_manager.py deleted file mode 100644 index 30b666207..000000000 --- a/webui/backend/deep_research_worker_manager.py +++ /dev/null @@ -1,207 +0,0 @@ -import asyncio -import json -import os -import signal -import sys -from datetime import datetime -from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, Optional - - -class DeepResearchWorkerManager: - - def __init__(self, send_event: Callable[[str, Dict[str, Any]], - Awaitable[None]]): - self._send_event = send_event - self._processes: Dict[str, asyncio.subprocess.Process] = {} - self._stdout_tasks: Dict[str, asyncio.Task] = {} - self._stderr_tasks: Dict[str, asyncio.Task] = {} - self._stopping: set[str] = set() - - def _get_repo_root(self) -> Path: - return Path(__file__).resolve().parents[2] - - def _get_worker_path(self) -> Path: - return Path(__file__).resolve().parent / 'deep_research_worker.py' - - def _build_env( - self, env_vars: Optional[Dict[str, str]], - llm_config: Optional[Dict[str, Any]], - deep_research_config: Optional[Dict[str, Any]]) -> Dict[str, str]: - env = os.environ.copy() - if env_vars: - env.update({k: v for k, v in env_vars.items() if v}) - if llm_config: - env['MS_AGENT_LLM_CONFIG'] = json.dumps( - llm_config, ensure_ascii=False) - if deep_research_config: - env['MS_AGENT_DEEP_RESEARCH_CONFIG'] = json.dumps( - deep_research_config, ensure_ascii=False) - - api_key = (llm_config or {}).get('api_key') - base_url = (llm_config or {}).get('base_url') - if api_key and not env.get('OPENAI_API_KEY'): - env['OPENAI_API_KEY'] = api_key - if base_url and not env.get('OPENAI_BASE_URL'): - env['OPENAI_BASE_URL'] = base_url - env['PYTHONUNBUFFERED'] = '1' - repo_root = str(self._get_repo_root()) - existing_path = env.get('PYTHONPATH', '') - if repo_root not in existing_path.split(os.pathsep): - env['PYTHONPATH'] = repo_root + ( - os.pathsep + existing_path if existing_path else '') - return env - - async def start( - self, - session_id: str, - *, - query: str, - config_path: str, - output_dir: str, - env_vars: Optional[Dict[str, str]] = None, - llm_config: Optional[Dict[str, Any]] = None, - deep_research_config: Optional[Dict[str, Any]] = None) -> None: - if session_id in self._processes: - await self.stop(session_id) - - worker_path = self._get_worker_path() - output_dir_path = Path(output_dir) - output_dir_path.mkdir(parents=True, exist_ok=True) - - cmd = [ - sys.executable, - str(worker_path), - '--config', - config_path, - '--query', - query, - '--session_id', - session_id, - '--output_dir', - str(output_dir_path), - ] - - env = self._build_env(env_vars, llm_config, deep_research_config) - - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - cwd=str(self._get_repo_root()), - start_new_session=True, - ) - - self._processes[session_id] = process - self._stdout_tasks[session_id] = asyncio.create_task( - self._read_stdout(session_id, process)) - self._stderr_tasks[session_id] = asyncio.create_task( - self._read_stderr(session_id, process)) - await self._send_event( - session_id, { - 'type': 'log', - 'level': 'info', - 'message': f'Deep research worker started (pid={process.pid})', - 'timestamp': datetime.now().isoformat(), - }) - - async def stop(self, session_id: str) -> None: - process = self._processes.get(session_id) - if not process: - return - - try: - self._stopping.add(session_id) - if process.returncode is None: - try: - os.killpg(process.pid, signal.SIGTERM) - except Exception: - try: - process.terminate() - except Exception: - pass - try: - await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: - try: - os.killpg(process.pid, signal.SIGKILL) - except Exception: - try: - process.kill() - except Exception: - pass - finally: - self._cleanup(session_id) - - async def _read_stdout(self, session_id: str, - process: asyncio.subprocess.Process) -> None: - if not process.stdout: - return - while True: - line = await process.stdout.readline() - if not line: - break - text = line.decode('utf-8', errors='replace').strip() - if not text: - continue - try: - event = json.loads(text) - except Exception: - continue - try: - await self._send_event(session_id, event) - except Exception: - pass - return_code = process.returncode - if return_code is None: - try: - return_code = await process.wait() - except Exception: - return_code = None - if return_code not in (None, 0) and session_id not in self._stopping: - await self._send_event( - session_id, { - 'type': - 'error', - 'message': - f'Deep research worker exited with code {return_code}', - }) - await self._send_event(session_id, { - 'type': 'status', - 'status': 'error' - }) - self._cleanup(session_id) - - async def _read_stderr(self, session_id: str, - process: asyncio.subprocess.Process) -> None: - if not process.stderr: - return - while True: - line = await process.stderr.readline() - if not line: - break - # Keep stderr for server logs; avoid polluting stdout stream. - try: - text = line.decode('utf-8', errors='replace') - sys.stderr.write(text) - sys.stderr.flush() - await self._send_event( - session_id, { - 'type': 'log', - 'level': 'error', - 'message': f'[deep_research_worker] {text.strip()}', - 'timestamp': datetime.now().isoformat(), - }) - except Exception: - pass - - def _cleanup(self, session_id: str) -> None: - task = self._stdout_tasks.pop(session_id, None) - if task: - task.cancel() - task = self._stderr_tasks.pop(session_id, None) - if task: - task.cancel() - self._processes.pop(session_id, None) - self._stopping.discard(session_id) diff --git a/webui/backend/main.py b/webui/backend/main.py deleted file mode 100644 index 8eb584bc1..000000000 --- a/webui/backend/main.py +++ /dev/null @@ -1,84 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -MS-Agent Web UI Backend Server -Provides REST API and WebSocket endpoints for the ms-agent framework. -""" -import os -import sys -import uvicorn -from api import router as api_router -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles -from websocket_handler import router as ws_router - -# Add ms-agent to path -MS_AGENT_PATH = os.path.abspath( - os.path.join(os.path.dirname(__file__), '..', '..', 'ms-agent')) -if MS_AGENT_PATH not in sys.path: - sys.path.insert(0, MS_AGENT_PATH) - -app = FastAPI( - title='MS-Agent Web UI', - description='Web interface for the MS-Agent framework', - version='1.0.0') - -# CORS configuration -app.add_middleware( - CORSMiddleware, - allow_origins=['*'], - allow_credentials=True, - allow_methods=['*'], - allow_headers=['*'], -) - -# Include API and WebSocket routers -app.include_router(api_router, prefix='/api') -app.include_router(ws_router, prefix='/ws') - -# Serve static files in production -STATIC_DIR = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist') -if os.path.exists(STATIC_DIR): - app.mount( - '/assets', - StaticFiles(directory=os.path.join(STATIC_DIR, 'assets')), - name='assets') - - @app.get('/{full_path:path}') - async def serve_spa(full_path: str): - """Serve the SPA for all non-API routes""" - file_path = os.path.join(STATIC_DIR, full_path) - if os.path.exists(file_path) and os.path.isfile(file_path): - return FileResponse(file_path) - return FileResponse(os.path.join(STATIC_DIR, 'index.html')) - - -@app.get('/health') -async def health_check(): - """Health check endpoint""" - return {'status': 'healthy', 'service': 'ms-agent-webui'} - - -def main(): - """Start the server""" - import argparse - parser = argparse.ArgumentParser(description='MS-Agent Web UI Server') - parser.add_argument('--host', default='0.0.0.0', help='Host to bind') - parser.add_argument('--port', type=int, default=7860, help='Port to bind') - parser.add_argument( - '--reload', action='store_true', help='Enable auto-reload') - args = parser.parse_args() - - print(f"\n{'='*60}") - print(' MS-Agent Web UI Server') - print(f"{'='*60}") - print(f' Server running at: http://{args.host}:{args.port}') - print(f' API documentation: http://{args.host}:{args.port}/docs') - print(f"{'='*60}\n") - - uvicorn.run('main:app', host=args.host, port=args.port, reload=args.reload) - - -if __name__ == '__main__': - main() diff --git a/webui/backend/project_discovery.py b/webui/backend/project_discovery.py deleted file mode 100644 index e30bf639d..000000000 --- a/webui/backend/project_discovery.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Project discovery module for MS-Agent Web UI -Discovers and manages available projects from the ms-agent/projects directory. -""" -import os -import re -from typing import Any, Dict, List, Optional - - -class ProjectDiscovery: - """Discovers and manages projects from the ms-agent projects directory""" - - # Whitelist of projects to show in the UI - VISIBLE_PROJECTS = {'code_genesis', 'singularity_cinema'} - - def __init__(self, projects_dir: str): - self.projects_dir = projects_dir - self._projects_cache: Optional[List[Dict[str, Any]]] = None - - def discover_projects(self, - force_refresh: bool = False) -> List[Dict[str, Any]]: - """Discover all available projects""" - if self._projects_cache is not None and not force_refresh: - return self._projects_cache - - projects = [] - - if not os.path.exists(self.projects_dir): - return projects - - for item in os.listdir(self.projects_dir): - item_path = os.path.join(self.projects_dir, item) - # Only show projects in the whitelist - if os.path.isdir(item_path) and not item.startswith( - '.') and item in self.VISIBLE_PROJECTS: - project_info = self._analyze_project(item, item_path) - if project_info: - projects.append(project_info) - - # Add virtual projects (non-top-level entries) - projects.extend(self._build_virtual_projects()) - - # Sort by display name - projects.sort(key=lambda x: x['display_name']) - self._projects_cache = projects - return projects - - def _build_virtual_projects(self) -> List[Dict[str, Any]]: - projects: List[Dict[str, Any]] = [] - - v2_root = os.path.join(self.projects_dir, 'deep_research', 'v2') - researcher_yaml = os.path.join(v2_root, 'researcher.yaml') - if os.path.exists(researcher_yaml): - readme_path = os.path.join(v2_root, 'README.md') - description = self._extract_description( - readme_path) if os.path.exists(readme_path) else '' - projects.append({ - 'id': 'deep_research_v2', - 'name': 'deep_research_v2', - 'display_name': 'Deep Research', - 'description': description, - 'type': 'agent', - 'path': v2_root, - 'has_readme': os.path.exists(readme_path), - 'config_file': researcher_yaml, - 'supports_workflow_switch': False - }) - - return projects - - def _analyze_project(self, name: str, - path: str) -> Optional[Dict[str, Any]]: - """Analyze a project directory and extract its information""" - # Check for workflow.yaml or agent.yaml - workflow_file = os.path.join(path, 'workflow.yaml') - simple_workflow_file = os.path.join(path, 'simple_workflow.yaml') - agent_file = os.path.join(path, 'agent.yaml') - run_file = os.path.join(path, 'run.py') - readme_file = os.path.join(path, 'README.md') - - # Determine project type - if os.path.exists(workflow_file): - project_type = 'workflow' - config_file = workflow_file - elif os.path.exists(agent_file): - project_type = 'agent' - config_file = agent_file - elif os.path.exists(run_file): - project_type = 'script' - config_file = run_file - else: - # Skip directories without valid config - return None - - # Check if project supports workflow switching (e.g., code_genesis) - supports_workflow_switch = False - if project_type == 'workflow' and name == 'code_genesis' and os.path.exists( - simple_workflow_file): - supports_workflow_switch = True - - # Generate display name from directory name - display_name = self._format_display_name(name) - - # Extract description from README if available - description = self._extract_description(readme_file) if os.path.exists( - readme_file) else '' - - return { - 'id': name, - 'name': name, - 'display_name': display_name, - 'description': description, - 'type': project_type, - 'path': path, - 'has_readme': os.path.exists(readme_file), - 'config_file': config_file, - 'supports_workflow_switch': supports_workflow_switch - } - - def _format_display_name(self, name: str) -> str: - """Convert directory name to display name""" - # Replace underscores with spaces and title case - display = name.replace('_', ' ').replace('-', ' ') - # Handle camelCase - display = re.sub(r'([a-z])([A-Z])', r'\1 \2', display) - return display.title() - - def _extract_description(self, readme_path: str) -> str: - """Extract first paragraph from README as description""" - try: - with open(readme_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Skip title and find first paragraph - lines = content.split('\n') - description_lines = [] - in_description = False - - for line in lines: - stripped = line.strip() - # Skip headers and empty lines at the beginning - if not in_description: - if stripped and not stripped.startswith( - '#') and not stripped.startswith('['): - in_description = True - description_lines.append(stripped) - else: - if stripped and not stripped.startswith('#'): - description_lines.append(stripped) - elif not stripped and description_lines: - break - - description = ' '.join(description_lines) - # Truncate if too long - if len(description) > 300: - description = description[:297] + '...' - return description - except Exception: - return '' - - def get_project(self, project_id: str) -> Optional[Dict[str, Any]]: - """Get a specific project by ID""" - projects = self.discover_projects() - for project in projects: - if project['id'] == project_id: - return project - return None - - def get_project_readme(self, project_id: str) -> Optional[str]: - """Get the README content for a project""" - project = self.get_project(project_id) - if not project or not project['has_readme']: - return None - - readme_path = os.path.join(project['path'], 'README.md') - try: - with open(readme_path, 'r', encoding='utf-8') as f: - return f.read() - except Exception: - return None - - def get_project_config(self, project_id: str) -> Optional[Dict[str, Any]]: - """Get the configuration for a project""" - project = self.get_project(project_id) - if not project: - return None - - try: - import yaml - with open(project['config_file'], 'r', encoding='utf-8') as f: - return yaml.safe_load(f) - except Exception: - return None diff --git a/webui/backend/pyproject.toml b/webui/backend/pyproject.toml new file mode 100644 index 000000000..3602f610a --- /dev/null +++ b/webui/backend/pyproject.toml @@ -0,0 +1,72 @@ +[project] +name = "ms-agent-webui-backend" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "anthropic>=0.117.0", + "exa-py>=2.16.0", + "fastapi>=0.139.0", + "httpx>=0.28.1", + "ipykernel>=7.3.0", + "jupyter-client>=8.9.1", + "loguru>=0.7.3", + "mem0ai>=2.0.12", + "ms-agent", + "pydantic-settings>=2.14.2", + "sse-starlette>=3.4.5", + "uvicorn[standard]>=0.50.2", +] + +[project.optional-dependencies] +# Offline embeddings for vector memory (fastembed drags in onnxruntime, +# ~100 MB of wheels + a one-time ~220 MB model download on first use): +# uv sync --extra local-embed +local-embed = ["fastembed>=0.8"] + +[project.scripts] +dev = "app.main:dev" +serve = "app.main:serve" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.uv] +# The launcher and docs rely on `uv sync --locked --inexact`; refuse +# outright on a uv too old to honour those flags. +required-version = ">=0.5.0" + +[[tool.uv.index]] +# 国内构建机与 CI 直连 pypi.org 极不稳定:uv sync 曾在 pillow/matplotlib/ +# modelscope 这几个几 MB 的 wheel 上反复重下八九分钟,最终 30s 超时构建失败 +# (2026-08-05,run 58179298)。锁文件会把这里的地址记进每个包的 registry, +# 所以换源必须重新 uv lock 才生效——只设环境变量对 --frozen 无效。 +# apt 与 uv 自身的安装早已固定在同一面镜像上(见 Dockerfile)。 +name = "aliyun" +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true + +[tool.uv.sources] +# ADAPTED FROM ms-agent-webui (upstream of this snapshot): there the SDK is a +# git pin; here the backend lives INSIDE the ms-agent checkout it runs against, +# so it must build from the containing tree. Editable keeps the two in step — +# otherwise `ms-agent ui` would silently serve a stale SDK after framework code +# changed right next to it. +# +# This block plus `uv.lock` are the only permanent differences from the source +# repo; every other file under webui/ is a byte-exact copy. Do not "fix" the +# rest here — it will be overwritten by the next snapshot (see docs/MIGRATION.md). +ms-agent = { path = "../..", editable = true } + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", +] diff --git a/webui/backend/session_manager.py b/webui/backend/session_manager.py deleted file mode 100644 index 1ee20587b..000000000 --- a/webui/backend/session_manager.py +++ /dev/null @@ -1,176 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Session management for MS-Agent Web UI -Handles session lifecycle and message history. -""" -import uuid -from datetime import datetime -from threading import Lock -from typing import Any, Dict, List, Optional - - -class SessionManager: - """Manages user sessions and their message history""" - - def __init__(self): - self._sessions: Dict[str, Dict[str, Any]] = {} - self._messages: Dict[str, List[Dict[str, Any]]] = {} - self._dr_events: Dict[str, List[Dict[str, Any]]] = {} - self._dr_event_counters: Dict[str, int] = {} - self._lock = Lock() - - def create_session(self, - project_id: str, - project_name: str, - workflow_type: str = 'standard', - session_type: str = 'project') -> Dict[str, Any]: - """Create a new session""" - session_id = str(uuid.uuid4()) - session = { - 'id': session_id, - 'project_id': project_id, - 'project_name': project_name, - 'status': 'idle', # idle, running, completed, error - 'created_at': datetime.now().isoformat(), - 'workflow_progress': None, - 'file_progress': None, - 'current_step': None, - 'workflow_type': workflow_type, # 'standard' or 'simple' - 'session_type': session_type # 'project' or 'chat' - } - - with self._lock: - self._sessions[session_id] = session - self._messages[session_id] = [] - self._dr_events[session_id] = [] - self._dr_event_counters[session_id] = 0 - - return session - - def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: - """Get session by ID""" - return self._sessions.get(session_id) - - def update_session(self, session_id: str, updates: Dict[str, Any]) -> bool: - """Update session data""" - if session_id not in self._sessions: - return False - - with self._lock: - self._sessions[session_id].update(updates) - return True - - def delete_session(self, session_id: str) -> bool: - """Delete a session""" - with self._lock: - if session_id in self._sessions: - del self._sessions[session_id] - if session_id in self._messages: - del self._messages[session_id] - if session_id in self._dr_events: - del self._dr_events[session_id] - if session_id in self._dr_event_counters: - del self._dr_event_counters[session_id] - return True - return False - - def list_sessions(self) -> List[Dict[str, Any]]: - """List all sessions""" - return list(self._sessions.values()) - - def add_message(self, - session_id: str, - role: str, - content: str, - message_type: str = 'text', - metadata: Dict[str, Any] = None) -> bool: - """Add a message to a session""" - if session_id not in self._sessions: - return False - - message = { - 'id': str(uuid.uuid4()), - 'role': role, # user, assistant, system, tool - 'content': content, - 'type': message_type, # text, tool_call, tool_result, error, log - 'timestamp': datetime.now().isoformat(), - 'metadata': metadata or {} - } - - with self._lock: - if session_id not in self._messages: - self._messages[session_id] = [] - self._messages[session_id].append(message) - - return True - - def get_messages(self, session_id: str) -> Optional[List[Dict[str, Any]]]: - """Get all messages for a session""" - if session_id not in self._sessions: - return None - return self._messages.get(session_id, []) - - def add_dr_event(self, session_id: str, - event: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Add a deep research event for replay.""" - if session_id not in self._sessions: - return None - with self._lock: - next_id = self._dr_event_counters.get(session_id, 0) + 1 - self._dr_event_counters[session_id] = next_id - stored = dict(event) - stored['event_id'] = next_id - self._dr_events.setdefault(session_id, []).append(stored) - return stored - - def list_dr_events( - self, - session_id: str, - after_id: Optional[int] = None) -> Optional[List[Dict[str, Any]]]: - """List deep research events for a session.""" - if session_id not in self._sessions: - return None - events = self._dr_events.get(session_id, []) - if after_id is None: - return list(events) - return [ - event for event in events if event.get('event_id', 0) > after_id - ] - - def update_last_message(self, session_id: str, content: str) -> bool: - """Update the content of the last message (for streaming)""" - if session_id not in self._messages or not self._messages[session_id]: - return False - - with self._lock: - self._messages[session_id][-1]['content'] = content - return True - - def set_workflow_progress(self, session_id: str, - progress: Dict[str, Any]) -> bool: - """Set workflow progress for a session""" - if session_id not in self._sessions: - return False - - with self._lock: - self._sessions[session_id]['workflow_progress'] = progress - return True - - def set_file_progress(self, session_id: str, progress: Dict[str, - Any]) -> bool: - """Set file writing progress for a session""" - if session_id not in self._sessions: - return False - - with self._lock: - self._sessions[session_id]['file_progress'] = progress - return True - - def set_current_step(self, session_id: str, step: str) -> bool: - """Set the current workflow step""" - if session_id not in self._sessions: - return False - - with self._lock: - self._sessions[session_id]['current_step'] = step - return True diff --git a/webui/backend/shared.py b/webui/backend/shared.py deleted file mode 100644 index fb7b7fe67..000000000 --- a/webui/backend/shared.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Shared instances for backend modules. -Ensures api.py and websocket_handler.py use the same manager instances. -""" -import os -from config_manager import ConfigManager -from project_discovery import ProjectDiscovery -from session_manager import SessionManager - -# Initialize paths -BASE_DIR = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -PROJECTS_DIR = os.path.join(BASE_DIR, 'projects') -# Use ~/.ms_agent/ for configuration storage (privacy-sensitive data) -CONFIG_DIR = os.path.expanduser('~/.ms_agent') - -# Shared instances -project_discovery = ProjectDiscovery(PROJECTS_DIR) -config_manager = ConfigManager(CONFIG_DIR) -session_manager = SessionManager() - -print('[Shared] Initialized managers') -print(f'[Shared] Projects dir: {PROJECTS_DIR}') -print(f'[Shared] Config dir: {CONFIG_DIR}') diff --git a/webui/backend/tests/conftest.py b/webui/backend/tests/conftest.py new file mode 100644 index 000000000..924acd852 --- /dev/null +++ b/webui/backend/tests/conftest.py @@ -0,0 +1,21 @@ +"""Isolate the SDK home so tests never touch the real ~/.ms_agent.""" +import os +import tempfile + +os.environ["MS_AGENT_HOME"] = tempfile.mkdtemp(prefix="ms_agent_test_home_") +os.environ.setdefault("LOG_LEVEL", "ERROR") + +import pytest + +from app.backends.ms_agent import titler + + +@pytest.fixture(autouse=True) +def _stub_titler(monkeypatch): + """Keep the offline suite network-free: never let chat.stream fire the real + title/category LLM call. Tests that want a title override this per-test.""" + + async def _none(_text: str): + return None + + monkeypatch.setattr(titler, "generate_title_and_category", _none) diff --git a/webui/backend/tests/integration/test_chat_integration.py b/webui/backend/tests/integration/test_chat_integration.py new file mode 100644 index 000000000..90c36ba8a --- /dev/null +++ b/webui/backend/tests/integration/test_chat_integration.py @@ -0,0 +1,59 @@ +"""Integration tests hitting a real LLM. Opt-in: + + RUN_INTEGRATION=1 uv run pytest tests/integration + +Guards the two SessionLog-persistence-timing fixes (ms-agent run_loop): + * a turn's assistant reply is persisted at turn end (not one turn late), and + * resuming a session does not re-answer the previous user turn. +""" +import json +import os + +import pytest + +from app.core.settings import settings + +pytestmark = pytest.mark.skipif( + os.environ.get("RUN_INTEGRATION") != "1" or not settings.openai_api_key, + reason="integration test — set RUN_INTEGRATION=1 with a real LLM key in ../.env", +) + + +async def _turn(session_id, content): + from app.backends.ms_agent import chat + from app.schemas.chat import ChatMessage, ChatRequest + + req = ChatRequest(session_id=session_id, messages=[ChatMessage(role="user", content=content)]) + text, sid = "", session_id + async for frame in chat.stream(req): + d = json.loads(frame["data"]) + if d["type"] == "done": + sid = d["meta"]["session_id"] + break + if d["type"] == "text": + text += d["content"] + return sid, text + + +async def test_assistant_persisted_at_turn_end(): + from app.backends.ms_agent.bootstrap import bootstrap + from app.backends.ms_agent.common import find_session + + bootstrap() + sid, _ = await _turn(None, "Reply with exactly: pong") + _proj, sess, sm = find_session(sid) + roles = [m["role"] for m in sm.get_session_log(sess).get_all_messages()] + assert roles and roles[-1] == "assistant", f"assistant not persisted at turn end: {roles}" + + +async def test_resume_does_not_reanswer(): + from app.backends.ms_agent.bootstrap import bootstrap + from app.backends.ms_agent.runtime import registry + + bootstrap() + sid, _ = await _turn(None, "Remember the word MANGO. Reply with just: OK") + await registry.close_all() # simulate a restart -> forces rebuild + resume + sid2, t2 = await _turn(sid, "What word did I ask you to remember? One word.") + assert sid2 == sid + assert "MANGO" in t2.upper(), "context not restored on resume" + assert "OK" not in t2.upper().replace("MANGO", ""), f"resume re-answered previous turn: {t2!r}" diff --git a/webui/backend/tests/test_chat.py b/webui/backend/tests/test_chat.py new file mode 100644 index 000000000..6d557da48 --- /dev/null +++ b/webui/backend/tests/test_chat.py @@ -0,0 +1,3094 @@ +"""Chat event->ChatChunk mapping + turn termination on TURN_END.""" +import asyncio +import json +import os + +from app.backends.ms_agent import chat +from app.backends.ms_agent import runtime as rt_mod +from app.schemas.chat import ChatFile, ChatMessage, ChatRequest + + +def test_compose_prompt_plain_text_when_no_files(): + msg = ChatMessage(role="user", content="hello") + assert chat._compose_prompt(msg) == "hello" + + +def test_compose_prompt_appends_attached_files_block(): + msg = ChatMessage( + role="user", + content="summarize these", + files=[ + ChatFile(name="a.pdf", path="user_files/a.pdf"), + ChatFile(name="b.png", path="user_files/b.png"), + ], + ) + prompt = chat._compose_prompt(msg) + # The user's typed text is preserved and the workspace-relative file paths + # are listed so the agent can read the real bytes with its file tools. + assert prompt.startswith("summarize these") + assert "user_files/a.pdf" in prompt + assert "user_files/b.png" in prompt + + +def test_compose_prompt_files_only_turn(): + msg = ChatMessage(role="user", + content="", + files=[ + ChatFile(name="a.pdf", path="user_files/a.pdf"), + ]) + prompt = chat._compose_prompt(msg) + assert prompt and "user_files/a.pdf" in prompt + + +def test_turn_mapper_maps_new_protocol(): + m = chat._TurnMapper() + assert [c.type for c in m.map({ + "type": "content_delta", + "text": "hi" + })] == ["text"] + + # Reasoning streams incrementally: each delta is its own thought frame, and + # reasoning_ended emits a zero-width finalize frame carrying duration. + assert m.map({"type": "reasoning_started"}) == [] + delta = m.map({"type": "reasoning_delta", "text": "th"}) + assert [c.type for c in delta] == ["thought"] and delta[0].content == "th" + ended = m.map({"type": "reasoning_ended"}) + assert [c.type for c in ended] == ["thought"] + assert ended[0].content == "" and "duration" in ended[0].meta + + # plan -> task frames; status mapped; index-stable ids for in-place upsert. + tasks = m.map({ + "type": + "plan_updated", + "entries": [ + { + "content": "a", + "status": "in_progress" + }, + { + "content": "b", + "status": "completed" + }, + ] + }) + assert [(c.meta["id"], c.meta["status"]) + for c in tasks] == [("0", "running"), ("1", "done")] + + # A file_system read: started stashes it; completed emits the file_read step. + # tool_call_started now emits a live "running" card for immediate feedback; + # the completed event's step replaces it in place (matched by call_id). + run = m.map({ + "type": "tool_call_started", + "name": "file_system---read_file", + "call_id": "c1", + "arguments": { + "path": "a.md" + } + }) + assert [c.type for c in run] == ["step"] + assert run[0].meta == { + "kind": "file_read", + "path": "a.md", + "name": "file_system---read_file", + "tool": "file_system---read_file", + "arguments": { + "path": "a.md" + }, + "group": 1, + "call_id": "c1", + "status": "running", + } + step = m.map({ + "type": "tool_call_completed", + "call_id": "c1", + "name": "file_system---read_file", + "result": "# Title" + }) + assert [c.type for c in step] == ["step"] + assert step[0].meta == { + "kind": "file_read", + "path": "a.md", + "name": "file_system---read_file", + "tool": "file_system---read_file", + "arguments": { + "path": "a.md" + }, + "result": "# Title", + # Server-side tool-round grouping: this reply's tool-call set. + "group": 1, + "call_id": "c1", + } + + # A failed completion carries error status on the step (+ full invocation). + m.map({ + "type": "tool_call_started", + "name": "sh", + "call_id": "c2", + "arguments": {} + }) + err = m.map({ + "type": "tool_call_completed", + "call_id": "c2", + "name": "sh", + "error": "boom" + }) + assert err[0].meta == { + "kind": "tool_call", + "name": "sh", + "tool": "sh", + "arguments": {}, + "status": "error", + "error": "boom", + "source": "tool", + # c1's round fully drained before this started → a NEW round: the + # SDK emits one reply's whole tool_calls array as consecutive + # started events, so a started arriving after pending emptied is + # the next array's first element. + "group": 2, + "call_id": "c2", + } + + # todo_list / task_control calls are the timeline, not step cards — no + # running card on started either (their _tool_step_meta is None). + assert m.map({ + "type": "tool_call_started", + "name": "todo_list---todo_write", + "call_id": "c3", + "arguments": {} + }) == [] + assert m.map({ + "type": "tool_call_completed", + "call_id": "c3", + "name": "todo_list---todo_write" + }) == [] + + # A turn/API error -> a structured error frame. + er = m.map({ + "type": "error", + "message": "APIError: 401", + "recoverable": False + }) + assert [c.type for c in er] == ["error"] + assert er[0].meta == {"message": "APIError: 401", "recoverable": False} + # Unhandled events (content_end, ...) yield nothing. + assert m.map({"type": "content_end"}) == [] + + +def test_turn_mapper_maps_permission_request_to_authorization_step(): + """The generic card is what a tool WITHOUT its own card gets (an MCP tool here; + terminal / search / file asks fold into theirs — see _AUTH_INLINE_KINDS).""" + m = chat._TurnMapper("sid-9") + out = m.map({ + "type": "permission_request", + "request_id": "req-1", + "tool_name": "howtocook-mcp---whatToEat", + "tool_args": { + "people": 2 + }, + }) + # Emitted as a standalone authorization step (no task nesting). + assert [c.type for c in out] == ["step"] + meta = out[0].meta + assert meta["kind"] == "authorization" and meta["state"] == "pending" + assert meta["request_id"] == "req-1" and meta["session_id"] == "sid-9" + assert meta["tool_name"] == "howtocook-mcp---whatToEat" + assert "people" in meta["desc"] + assert meta["source"] == "mcp" + + +def test_turn_mapper_announces_a_timed_out_permission_as_rejected(): + """The SDK's ask() returns DENY silently when it times out, so the runtime + pushes `permission_resolved`. It must map to the ask's OWN card, in the + rejected state, WITHOUT a request_id — the decision is final, so the card must + stop offering buttons the moment it arrives (before the gated call's errored + result, which used to be the only hint).""" + m = chat._TurnMapper("sid-3") + # The ask came first and is still pending in the mapper's book-keeping. + m.map({ + "type": "tool_call_started", + "name": "code_executor---shell_executor", + "call_id": "c1", + "arguments": { + "command": "echo aaa" + }, + }) + out = m.map({ + "type": "permission_resolved", + "call_id": "c1", + "tool_name": "code_executor---shell_executor", + "tool_args": { + "command": "echo aaa" + }, + "state": "rejected", + }) + assert [c.type for c in out] == ["step"] + meta = out[0].meta + # Folded into the terminal card (same as its ask), so the refused command + # stays visible with a rejected badge. + assert meta["kind"] == "terminal" and meta["code"] == "echo aaa" + assert meta["state"] == "rejected" + assert meta["call_id"] == "c1" # replaces the pending ask in place + assert meta["group"] == 1 + assert "request_id" not in meta + + +def test_turn_mapper_announced_rejection_uses_the_generic_card_when_not_inlined(): + m = chat._TurnMapper("sid-3") + out = m.map({ + "type": "permission_resolved", + "call_id": "c2", + "tool_name": "howtocook-mcp---whatToEat", + "tool_args": { + "people": 2 + }, + "state": "rejected", + }) + assert out[0].meta["kind"] == "authorization" + assert out[0].meta["state"] == "rejected" + assert "request_id" not in out[0].meta + + +def test_turn_mapper_maps_shell_permission_request_to_terminal_card(): + """A shell ask is hosted by its own TERMINAL card (command as a code block + + Reject/Run), not by the generic "call tool" + JSON arguments card: the + command is what the user judges. It still carries every authorization field, + so the decision resolves from that same card.""" + m = chat._TurnMapper("sid-9") + out = m.map({ + "type": "permission_request", + "request_id": "req-2", + "call_id": "c9", + "tool_name": "code_executor---shell_executor", + "tool_args": { + "command": "brew install --cask flutter" + }, + }) + assert [c.type for c in out] == ["step"] + meta = out[0].meta + assert meta["kind"] == "terminal" + assert meta["code"] == "brew install --cask flutter" + assert meta["state"] == "pending" + assert meta["request_id"] == "req-2" and meta["session_id"] == "sid-9" + # Kept so the frontend can still merge the tool's result into this card. + assert meta["call_id"] == "c9" + assert meta["tool_name"] == "code_executor---shell_executor" + + +def test_turn_mapper_maps_search_permission_request_to_search_card(): + """A web-search ask is hosted by the SEARCH card, carrying the query. Left on + the generic "call tool" card, everything the user saw while the search ran was + the raw `web_search---exa_search` tool name.""" + m = chat._TurnMapper("sid-7") + out = m.map({ + "type": "permission_request", + "request_id": "req-3", + "call_id": "c8", + "tool_name": "web_search---exa_search", + "tool_args": { + "query": "today's tech news" + }, + }) + meta = out[0].meta + assert meta["kind"] == "search" and meta["scope"] == "web" + assert meta["query"] == "today's tech news" + assert meta["state"] == "pending" + assert meta["request_id"] == "req-3" and meta["call_id"] == "c8" + + +def test_attach_replay_of_running_search_keeps_the_searching_shape(): + """Refreshing mid-search: the attach replay hands back the RESOLVED ask + (state=approved + a live request_id) — there is no `running` status on that + frame. The search card keys its "searching …" row on exactly this shape, so + the contract is locked here: approved + request_id + no result. + """ + m = chat._TurnMapper( + "sid-5", + resolved_permissions=[{ + "tool_name": "web_search---exa_search", + "call_id": "c4", + "state": "approved", + }], + ) + out = m.map({ + "type": "permission_request", + "request_id": "req-9", + "call_id": "c4", + "tool_name": "web_search---exa_search", + "tool_args": { + "query": "stock market" + }, + }) + meta = out[0].meta + assert meta["kind"] == "search" and meta["scope"] == "web" + assert meta["state"] == "approved" + assert meta["request_id"] == "req-9" + assert meta["query"] == "stock market" + assert "result" not in meta and meta.get("status") is None + + +def test_turn_mapper_maps_file_permission_request_to_the_file_card(): + """A file operation's ask is hosted by its own file card, carrying the path — + that is what the user judges. On the generic "call tool" card all they saw was + `file_system---write_file` plus a JSON blob.""" + m = chat._TurnMapper("sid-6") + out = m.map({ + "type": "permission_request", + "request_id": "req-7", + "call_id": "c7", + "tool_name": "file_system---write_file", + "tool_args": { + "path": "a.txt", + "content": "hello" + }, + }) + meta = out[0].meta + assert meta["kind"] == "file_write" and meta["path"] == "a.txt" + assert meta["state"] == "pending" + assert meta["request_id"] == "req-7" and meta["call_id"] == "c7" + # read_file / edit_file keep their own kinds too (distinct wording per op). + assert chat._TurnMapper("s").map({ + "type": "permission_request", + "tool_name": "file_system---read_file", + "tool_args": { + "path": "b.md" + }, + })[0].meta["kind"] == "file_read" + assert chat._TurnMapper("s").map({ + "type": "permission_request", + "tool_name": "file_system---edit_file", + "tool_args": { + "path": "c.py" + }, + })[0].meta["kind"] == "file_edit" + + +def test_turn_mapper_keeps_generic_card_for_other_permission_requests(): + """Only _AUTH_INLINE_KINDS fold in; an MCP tool still asks on the generic + authorization card.""" + m = chat._TurnMapper("sid-7") + out = m.map({ + "type": "permission_request", + "request_id": "req-4", + "tool_name": "howtocook-mcp---whatToEat", + "tool_args": { + "n": 1 + }, + }) + assert out[0].meta["kind"] == "authorization" + + +def test_turn_mapper_emits_standalone_step_for_toolcall_without_plan(): + m = chat._TurnMapper() + m.map({ + "type": "tool_call_started", + "name": "sh", + "call_id": "c1", + "arguments": { + "a": 1 + } + }) + out = m.map({"type": "tool_call_completed", "call_id": "c1", "name": "sh"}) + # No plan needed: the tool call is emitted as its own linear step, carrying + # its full invocation (tool + arguments) and its call_id (so the frontend + # replaces the live "running" card emitted on start). + assert [c.type for c in out] == ["step"] + assert out[0].meta == { + "kind": "tool_call", + "name": "sh", + "tool": "sh", + "arguments": { + "a": 1 + }, + "source": "tool", + "group": 1, + "call_id": "c1", + } + + +def test_running_card_emitted_on_start_and_flush_keeps_call_id(): + """tool_call_started emits a live 'running' card (immediate feedback for slow + tools like web_search). If the turn is interrupted before completion, flush() + emits the sealed card with the SAME call_id so the frontend flips the running + card in place rather than leaving a stuck spinner.""" + m = chat._TurnMapper() + run = m.map({ + "type": "tool_call_started", + "name": "web_search---exa_search", + "call_id": "c1", + "arguments": {"query": "今天科技新闻"}, + }) + assert [c.type for c in run] == ["step"] + assert run[0].meta["kind"] == "search" + assert run[0].meta["status"] == "running" + assert run[0].meta["call_id"] == "c1" + assert run[0].meta["query"] == "今天科技新闻" + + flushed = m.flush() + steps = [c for c in flushed if c.type == "step"] + assert len(steps) == 1 + assert steps[0].meta["call_id"] == "c1" + assert steps[0].meta["status"] == "error" + + +def test_tool_step_meta_splits_the_three_skills_tools(): + """The skills server exposes skills_list / skill_view / skill_manage — three + unrelated actions. Only skill_view loads a skill, so only it may map to + skill_load; the others used to borrow that same "load skill" card and read + nonsensically (e.g. "load skill skills_list").""" + meta = chat._tool_step_meta + + # skills_list: catalog listing, or a SEARCH when a query is given. + assert meta("skills---skills_list", {}) == { + "kind": "skill_list", + "name": "skills---skills_list", + } + assert meta("skills---skills_list", {"query": "docker"}) == { + "kind": "skill_list", + "name": "skills---skills_list", + "query": "docker", + } + + # skill_view: the one that loads a skill. Reading one FILE inside the skill + # says so in the display name. + assert meta("skills---skill_view", {"skill_id": "docker-expert"}) == { + "kind": "skill_load", + "name": "docker-expert", + } + assert meta("skills---skill_view", { + "skill_id": "docker-expert", + "file_path": "scripts/build.py", + }) == { + "kind": "skill_load", + "name": "docker-expert/scripts/build.py", + } + + # skill_manage: create / edit / delete, distinguished by `action`. + assert meta("skills---skill_manage", { + "action": "create", + "skill_id": "my-skill", + "content": "...", + }) == { + "kind": "skill_manage", + "name": "skills---skill_manage", + "action": "create", + "skill": "my-skill", + } + assert meta("skills---skill_manage", { + "action": "delete", + "skill_id": "my-skill", + })["action"] == "delete" + + +def test_tool_step_meta_maps_full_taxonomy(): + """The `server---tool` taxonomy maps to specialized step kinds (matching on + the full name, not the short leaf).""" + meta = chat._tool_step_meta + + # code_executor -> terminal, carrying the command / code body. + assert meta("code_executor---shell_executor", {"command": "ls -la"}) == { + "kind": "terminal", + "name": "code_executor---shell_executor", + "code": "ls -la" + } + assert meta("code_executor---python_executor", {"code": "print(1)"}) == { + "kind": "terminal", + "name": "code_executor---python_executor", + "code": "print(1)" + } + + # web_search -> search (query); fetch_page -> browser (url). + assert meta("web_search---tavily_search", {"query": "ai agents"}) == { + "kind": "search", + "name": "web_search---tavily_search", + "query": "ai agents", + "scope": "web" + } + assert meta("web_search---fetch_page", {"url": "http://x"}) == { + "kind": "browser", + "name": "web_search---fetch_page", + "url": "http://x" + } + + # file_system write vs edit -> DISTINCT kinds (a full-content write and an + # in-place edit render as different cards). + assert meta("file_system---write_file", { + "path": "a.md", + "content": "x" + }) == { + "kind": "file_write", + "path": "a.md", + "name": "file_system---write_file" + } + assert meta("file_system---edit_file", { + "path": "a.md", + "old_string": "x", + "new_string": "y" + }) == { + "kind": "file_edit", + "path": "a.md", + "name": "file_system---edit_file" + } + + # file_system grep/glob -> search over the workspace. + assert meta("file_system---grep", {"pattern": "TODO"}) == { + "kind": "search", + "name": "file_system---grep", + "query": "TODO", + "scope": "files" + } + + # unified_memory -> memory (action); memory_read implies a read. + assert meta("unified_memory---memory", { + "action": "add", + "content": "x" + }) == { + "kind": "memory", + "name": "unified_memory---memory", + "action": "add" + } + assert meta("unified_memory---memory_read", {}) == { + "kind": "memory", + "name": "unified_memory---memory_read", + "action": "read" + } + + # Unknown / MCP tools -> the generic tool_call card by unified name. + assert meta("my_mcp---do_thing", {"a": 1}) == { + "kind": "tool_call", + "name": "my_mcp---do_thing", + # Non-builtin `server---tool` → an MCP call (UI: "call MCP"). + "source": "mcp" + } + # Plan machinery is still dropped. + assert meta("todo_list---todo_write", {}) is None + + +def test_turn_mapper_flags_plan_touched_on_todo_write(): + """A completed todo_write leaves no step card, but flags the mapper so the + loop's changed-files summary can include the session "plan.md"; a FAILED + todo_write doesn't count (nothing was written).""" + m = chat._TurnMapper() + assert m.plan_touched is False + m.map({ + "type": "tool_call_started", + "name": "todo_list---todo_write", + "call_id": "c1", + "arguments": {} + }) + assert m.map({ + "type": "tool_call_completed", + "call_id": "c1", + "name": "todo_list---todo_write" + }) == [] + assert m.plan_touched is True + + failed = chat._TurnMapper() + failed.map({ + "type": "tool_call_started", + "name": "todo_list---todo_write", + "call_id": "c2", + "arguments": {} + }) + failed.map({ + "type": "tool_call_completed", + "call_id": "c2", + "name": "todo_list---todo_write", + "error": "boom" + }) + assert failed.plan_touched is False + + +def test_changed_files_in_rows_includes_plan_write(): + """The drain-side derivation counts todo_write rows as "plan.md" alongside + workspace write/edit paths (deduped, first-write order).""" + from app.backends.ms_agent.sessions import changed_files_in_rows + + rows = [ + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_write", + "arguments": { + "todos": [] + } + }, + { + "id": "c2", + "tool_name": "file_system---edit_file", + "arguments": { + "path": "a.md" + } + }, + ] + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c3", + "tool_name": "todo_list---todo_write", # dup → once + "arguments": { + "todos": [] + } + }, + ] + }, + ] + assert changed_files_in_rows(rows) == ["plan.md", "a.md"] + + +def test_changed_files_excludes_out_of_workspace_and_custom_plan(tmp_path): + """With a workspace root, a write that resolves OUTSIDE the workspace (the + model copying its plan into the session dir via ``..``) and a plan file + under a NON-plan.md name (identified by the todo tool's own render report — + no filename heuristic) are both kept out of the summary; real in-workspace + deliverables stay, workspace-relative.""" + from app.backends.ms_agent.sessions import changed_files_in_rows + + ws = str(tmp_path) + rows = [ + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_write", + "arguments": { + "todos": [] + } + }, + ] + }, + # model renders the plan markdown into the workspace under a custom name + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c2", + "tool_name": "todo_list---todo_render_md", + "arguments": { + "path": "roadmap_plan.md" + } + }, + ] + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "OK: rendered plan markdown to roadmap_plan.md" + }, + { + "role": + "assistant", + "tool_calls": [ + # a genuine workspace deliverable + { + "id": "c3", + "tool_name": "file_system---write_file", + "arguments": { + "path": "report.txt" + } + }, + # model copies the plan into the session dir (outside ws via ..) + { + "id": "c4", + "tool_name": "file_system---write_file", + "arguments": { + "path": "../sess/plan_copy.md" + } + }, + # model rewrites the custom-named plan the render produced: matches + # a reported plan path -> excluded (not a deliverable) + { + "id": "c5", + "tool_name": "file_system---edit_file", + "arguments": { + "path": "roadmap_plan.md" + } + }, + ] + }, + ] + got = changed_files_in_rows(rows, ws) + assert "report.txt" in got + assert "roadmap_plan.md" not in got # custom-named plan, by tool report + assert not any("plan_copy.md" in p for p in got) # out-of-ws write + assert not any(".." in p for p in got) # no escapes leak through + assert "plan.md" in got # the reserved plan marker still present + + +def test_plan_paths_and_latest_rendered_md(tmp_path): + """plan_paths_in_rows collects plan locations from todo reports (write's + plan_path + its .md twin, render targets) and loop_end plan_file; + latest_rendered_plan_md returns the last rendered markdown target.""" + from app.backends.ms_agent.sessions import ( + latest_rendered_plan_md, + plan_paths_in_rows, + ) + + ws = str(tmp_path) + rows = [ + { + "role": "tool", + "content": '{"status":"ok","plan_path":"plan.json"}' + }, + { + "role": "tool", + "content": "OK: rendered plan markdown to a_plan.md" + }, + { + "role": "tool", + "content": "OK: rendered plan markdown to b_plan.md" + }, + { + "_type": "loop_end", + "plan_file": str(tmp_path / "sess" / "x.md") + }, + ] + paths = plan_paths_in_rows(rows, ws) + assert os.path.join(ws, "plan.json") in paths + assert os.path.join(ws, "plan.md") in paths # .json twin + assert os.path.join(ws, "a_plan.md") in paths + assert os.path.join(ws, "b_plan.md") in paths + assert os.path.normpath(str(tmp_path / "sess" / "x.md")) in paths + assert latest_rendered_plan_md(rows, ws) == os.path.join(ws, "b_plan.md") + + +def test_mapper_render_md_marks_plan_and_records_report(): + """A live todo_render_md completion flags plan_touched and stashes its + rendered target (raw), so loop end points plan_file at the fresh markdown + and keeps that file out of the changed-files summary — regardless of name.""" + m = chat._TurnMapper() + m.map({ + "type": "tool_call_started", + "name": "todo_list---todo_render_md", + "call_id": "c1", + "arguments": { + "path": "custom_plan.md" + } + }) + out = m.map({ + "type": "tool_call_completed", + "call_id": "c1", + "name": "todo_list---todo_render_md", + "result": "OK: rendered plan markdown to custom_plan.md" + }) + assert out == [] # plan machinery emits no step card + assert m.plan_touched is True + assert m.plan_reports == ["custom_plan.md"] + assert m.latest_plan_md_report == "custom_plan.md" + + +def test_tool_step_surfaces_duration_ms(): + """The SDK's per-tool duration_s is surfaced on the step meta as duration_ms + (live), independent of the tool's card kind.""" + m = chat._TurnMapper() + m.map({ + "type": "tool_call_started", + "name": "code_executor---shell_executor", + "call_id": "c1", + "arguments": { + "command": "ls" + } + }) + out = m.map({ + "type": "tool_call_completed", + "call_id": "c1", + "name": "code_executor---shell_executor", + "result": "ok", + "duration_s": 1.18 + }) + assert out[0].meta["kind"] == "terminal" + assert out[0].meta["duration_ms"] == 1180 + + +def test_reconstruct_preserves_text_tool_text_order(): + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "hi" + }, + { + "role": "assistant", + "content": "before" + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---read_file", + "arguments": '{"path": "a.md"}' + }, + ] + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "file body" + }, + { + "role": "assistant", + "content": "after" + }, + ] + msgs = _reconstruct(rows) + assert [m.role for m in msgs] == ["user", "assistant"] + parts = msgs[1].parts + # text "before" -> step (file_read) -> text "after", in stream order. + assert [p.kind for p in parts] == ["text", "step", "text"] + assert parts[0].text == "before" and parts[2].text == "after" + step = parts[1].step + assert step.kind == "file_read" + # Full invocation is carried so the detail drawer shows tool + args + result. + assert step.meta["tool"] == "file_system---read_file" + assert step.meta["arguments"] == {"path": "a.md"} + assert step.meta["result"] == "file body" + # content stays the joined answer text for fallback consumers. + assert msgs[1].content == "before\n\nafter" + + +def test_reconstruct_surfaces_errors_and_failed_steps(): + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "hi", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---read_file", + "arguments": '{"path": "x.md"}' + }, + ], + "seq": + 1 + }, + # failed tool result -> marks its step; API/turn error -> a part of + # the SAME turn (flushing it as its own message closed the turn before + # its loop_end marker arrived, dropping the turn's duration on replay). + { + "role": "tool", + "tool_call_id": "c1", + "content": "no such file", + "is_error": True, + "seq": 2 + }, + { + "_type": "error", + "message": "APIError: 500", + "recoverable": False, + "seq": 3 + }, + { + "_type": "loop_end", + "duration_ms": 4200, + "seq": 4 + }, + ] + msgs = _reconstruct(rows) + + assert [m.role for m in msgs] == ["user", "assistant"] + step = msgs[1].parts[0].step + # A failed file op KEEPS its file kind (the card renders the accordion with + # arguments + error itself). It used to be re-kinded to `tool_call` to get + # that accordion, which replayed as "call tool file_system---read_file" while + # the live stream showed the file. + assert step.kind == "file_read" and step.meta["status"] == "error" + assert step.meta["error"] == "no such file" + error_parts = [p for p in msgs[1].parts if p.kind == "error"] + assert len(error_parts) == 1 and error_parts[0].recoverable is False + assert "APIError: 500" in error_parts[0].text + # The loop_end lands after the error record; accumulating (not flushing) + # is what lets the errored turn keep its wall-clock duration on replay. + assert msgs[1].duration_ms == 4200 + + +def test_reconstruct_replays_reasoning_and_skips_compacted_rows(): + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "hi", + "seq": 0 + }, + # Persisted reasoning replays as a thought part before the answer. + { + "role": "assistant", + "content": "answer", + "reasoning_content": "chain of thought", + "seq": 1 + }, + # Compaction re-appends of earlier rows (the squeezed LLM view + the + # synthetic summary) must not duplicate the timeline. + { + "role": "user", + "content": "[Conversation Summary] ...", + "_source": "compaction", + "seq": 2 + }, + { + "role": "assistant", + "content": "answer", + "_source": "compaction", + "seq": 3 + }, + ] + msgs = _reconstruct(rows) + assert [m.role for m in msgs] == ["user", "assistant"] + assert [p.kind for p in msgs[1].parts] == ["thought", "text"] + assert msgs[1].parts[0].text == "chain of thought" + assert msgs[1].content == "answer" + + +def test_reconstruct_replays_permission_and_durations(): + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "write it", + "seq": 0 + }, + # A restricted-mode authorization (persisted _type=permission), replayed + # as a resolved auth card in the assistant turn. + { + "_type": "permission", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + }, + "state": "approved", + "seq": 1 + }, + { + "role": + "assistant", + "reasoning_content": + "let me write", + "reasoning_duration": + 4, + "tool_calls": [{ + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": '{"path": "a.md"}' + }], + "seq": + 2 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "duration_ms": 1180, + "seq": 3 + }, + ] + msgs = _reconstruct(rows) + assert [m.role for m in msgs] == ["user", "assistant"] + kinds = [p.kind for p in msgs[1].parts] + # The permission row persists eagerly (seq=1, before the round-boundary + # assistant/tool rows) and must be MATCHED to the call it gated (the dict args + # vs the tool_call's JSON-string args — exercises _perm_key normalization). + # A file ask lives on the file card itself now, so an APPROVED one replays as + # nothing: its tool step below already shows the same path. Had the match + # failed, the card would have flushed at turn end as a third part. + assert kinds == ["thought", "step"] + assert msgs[1].parts[0].duration == 4 + tool_step = msgs[1].parts[1].step + assert tool_step.kind == "file_write" and tool_step.meta[ + "duration_ms"] == 1180 + + +def test_reconstruct_pairs_each_permission_with_its_tool_step(): + """Two gated calls in one turn: each auth card sits immediately before the + tool step it authorized (FIFO match on tool+args), so a rejected write and + its errored result render adjacent — the case the frontend merges.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "write two files", + "seq": 0 + }, + { + "_type": "permission", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + }, + "state": "approved", + "seq": 1 + }, + { + "_type": "permission", + "tool_name": "file_system---write_file", + "arguments": { + "path": "b.md" + }, + "state": "rejected", + "seq": 2 + }, + { + "role": + "assistant", + "reasoning_content": + "writing", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + } + }, + { + "id": "c2", + "tool_name": "file_system---write_file", + "arguments": { + "path": "b.md" + } + }, + ], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "seq": 4 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "Tool call denied", + "is_error": True, + "seq": 5 + }, + ] + parts = _reconstruct(rows)[1].parts + kinds = [(p.kind, (p.step.kind if p.kind == "step" else None), + (p.step.meta.get("state") or p.step.meta.get("status") + if p.kind == "step" else None)) for p in parts] + # thought, then a.md's write (its APPROVED ask card is dropped — a file ask + # rides the file card, so the step itself tells the story), then b.md's + # REFUSED ask, which keeps its card at the call's original position while its + # denied tool step is dropped by _history_step (showing both duplicated the + # rejection). + assert kinds == [ + ("thought", None, None), + ("step", "file_write", None), + ("step", "file_write", "rejected"), + ] + + +def test_reconstruct_replays_shell_permission_as_terminal_card(): + """History mirrors the live shape for shell asks: a REJECTED command replays + as its terminal card (code + rejected state) while its denied tool step is + dropped; an APPROVED one drops the ask card instead, since the replayed + terminal tool step already shows the same command — exactly what the live + stream ends up with once the result replaces the ask in place.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "run them", + "seq": 0 + }, + { + "_type": "permission", + "tool_name": "code_executor---shell_executor", + "arguments": { + "command": "ls" + }, + "state": "approved", + "seq": 1 + }, + { + "_type": "permission", + "tool_name": "code_executor---shell_executor", + "arguments": { + "command": "rm -rf /" + }, + "state": "rejected", + "seq": 2 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "code_executor---shell_executor", + "arguments": { + "command": "ls" + } + }, + { + "id": "c2", + "tool_name": "code_executor---shell_executor", + "arguments": { + "command": "rm -rf /" + } + }, + ], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "a.md", + "seq": 4 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "Tool call denied", + "is_error": True, + "seq": 5 + }, + ] + parts = _reconstruct(rows)[1].parts + steps = [p.step for p in parts if p.kind == "step"] + assert [s.kind for s in steps] == ["terminal", "terminal"] + # Approved: only the tool step (no duplicate ask card above it). + assert steps[0].meta["code"] == "ls" and "state" not in steps[0].meta + # Rejected: the ask card itself, carrying the command it refused. + assert steps[1].meta["code"] == "rm -rf /" + assert steps[1].meta["state"] == "rejected" + + +def test_reconstruct_unmatched_permission_still_renders(): + """An auth record with no matching tool step (unusual) is not dropped — it + flushes at turn end so the decision stays visible.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "hi", + "seq": 0 + }, + { + "_type": "permission", + "tool_name": "some---tool", + "arguments": { + "x": 1 + }, + "state": "approved", + "seq": 1 + }, + { + "role": "assistant", + "content": "done", + "seq": 2 + }, + ] + parts = _reconstruct(rows)[1].parts + assert any(p.kind == "step" and p.step.kind == "authorization" + for p in parts) + + +def test_reconstruct_pairs_permission_by_call_id_exactly(): + """Two IDENTICAL (tool+args) calls in one round with opposite outcomes: the + call_id link pairs each auth card to its true call — the corner args-FIFO + can't disambiguate. Permission ask-order is reversed vs the tool_calls array + (parallel scheduling) to prove ordering-independence.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "twice", + "seq": 0 + }, + # asks recorded in the OPPOSITE order to the tool_calls array below + { + "_type": "permission", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + }, + "state": "rejected", + "call_id": "c2", + "seq": 1 + }, + { + "_type": "permission", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + }, + "state": "approved", + "call_id": "c1", + "seq": 2 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + } + }, + { + "id": "c2", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.md" + } + }, + ], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "seq": 4 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "denied", + "is_error": True, + "seq": 5 + }, + ] + parts = _reconstruct(rows)[1].parts + steps = [(p.step.kind, p.step.meta.get("state"), p.step.meta.get("result")) + for p in parts if p.kind == "step"] + # c1 (first in the array) was approved — a file ask rides the file card, so the + # approved one drops and its successful step shows. c2 was rejected — its card + # survives at c2's position while its denied step drops. Pairing by ARGS/order + # instead of call_id would swap them, putting the rejected card first. + assert steps == [ + ("file_write", None, "ok"), + ("file_write", "rejected", None), + ] + + +def test_reconstruct_populates_changed_files(): + """A turn's write/edit tool_calls populate the assistant message's + changed_files (deduped, first-write order) for the loop summary; a + todo_write contributes the session plan file ("plan.md").""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "build", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": { + "path": "index.html" + } + }, + { + "id": "c2", + "tool_name": "file_system---edit_file", + "arguments": { + "path": "style.css" + } + }, + { + "id": "c3", + "tool_name": "file_system---write_file", + "arguments": { + "path": "index.html" + } + }, # dup → once + { + "id": "c4", + "tool_name": "file_system---read_file", + "arguments": { + "path": "notes.txt" + } + }, # read → excluded + { + "id": "c5", + "tool_name": "todo_list---todo_write", + "arguments": { + "todos": [] + } + }, # plan write → "plan.md" + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "seq": 2 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "ok", + "seq": 3 + }, + { + "role": "tool", + "tool_call_id": "c3", + "content": "ok", + "seq": 4 + }, + { + "role": "tool", + "tool_call_id": "c4", + "content": "text", + "seq": 5 + }, + { + "role": "tool", + "tool_call_id": "c5", + "content": "ok", + "seq": 6 + }, + { + "role": "assistant", + "content": "done", + "seq": 7 + }, + ] + msg = _reconstruct(rows)[1] + assert msg.changed_files == ["index.html", "style.css", "plan.md"] + + +def test_reconstruct_reads_loop_end_duration(): + """A persisted loop_end marker supplies the turn's duration_ms on replay + (changed_files stays derived from the tool_calls). Its seq lands after the + turn's rows, before the next user row.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "build", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": { + "path": "a.py" + } + }, + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "seq": 2 + }, + { + "role": "assistant", + "content": "done", + "seq": 3 + }, + { + "_type": "loop_end", + "duration_ms": 8142, + "changed_files": ["a.py"], + "seq": 4 + }, + ] + msg = _reconstruct(rows)[1] + assert msg.duration_ms == 8142 + assert msg.changed_files == ["a.py"] # derived, matches the marker + assert msg.plan_file is None # no plan write this turn + + +def test_reconstruct_reads_loop_end_plan_file(): + """A loop_end marker carrying ``plan_file`` (turn rewrote the todo list) + surfaces it on the assistant message, alongside the derived "plan.md" + changed-files entry — the frontend keys the plan chip off these.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "plan it", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_write", + "arguments": { + "todos": [] + } + }, + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok", + "seq": 2 + }, + { + "role": "assistant", + "content": "done", + "seq": 3 + }, + { + "_type": "loop_end", + "duration_ms": 900, + "changed_files": ["plan.md"], + "plan_file": + "/home/u/.ms_agent_webui/projects/p/sessions/s/plan.md", + "seq": 4 + }, + ] + msg = _reconstruct(rows)[1] + assert msg.changed_files == ["plan.md"] + assert msg.plan_file == "/home/u/.ms_agent_webui/projects/p/sessions/s/plan.md" + + +def test_plan_md_path_resolution(): + """_plan_md_path mirrors the todo tool's join: an absolute configured + plan_md_filename wins outright; a relative one lands under output_dir; + unresolvable configs return None.""" + + def rt_with(plan_md, output_dir): + tool = type("T", (), {"plan_md_filename": plan_md})() + tools = type("Ts", (), {"todo_list": tool})() + cfg = type("C", (), {"tools": tools, "output_dir": output_dir})() + agent = type("A", (), {"config": cfg})() + return type("R", (), {"agent": agent})() + + assert chat._plan_md_path(rt_with("/abs/sessions/s1/plan.md", + "/ws")) == "/abs/sessions/s1/plan.md" + assert chat._plan_md_path(rt_with("plan.md", "/ws")) == "/ws/plan.md" + assert chat._plan_md_path(rt_with("", "")) is None + + +def test_plan_md_path_prefers_canonical_over_render_target(): + """The canonical configured plan.md (what todo_write maintains and GET /plan + serves) is the plan_file pointer; a todo_render_md target is only a fallback + when the config can't be resolved — a render to a custom name does NOT + hijack the pointer away from the canonical plan.""" + + def rt_with(plan_md, output_dir, ws=None): + tool = type("T", (), {"plan_md_filename": plan_md})() + tools = type("Ts", (), {"todo_list": tool})() + cfg = type("C", (), {"tools": tools, "output_dir": output_dir})() + agent = type("A", (), {"config": cfg})() + project = type("P", (), {"path": ws})() if ws else None + return type("R", (), {"agent": agent, "project": project})() + + # Config resolvable -> canonical wins, render target ignored. + rt = rt_with("/abs/sessions/s1/plan.md", "/ws", ws="/ws") + assert chat._plan_md_path(rt, + "custom_plan.md") == "/abs/sessions/s1/plan.md" + # Config unresolvable -> fall back to the render target (resolved vs ws). + rt2 = rt_with("", "", ws="/ws") + assert chat._plan_md_path(rt2, "custom_plan.md") == "/ws/custom_plan.md" + + +def test_reconstruct_no_loop_end_leaves_duration_none(): + """Turns predating the marker still reconstruct; duration_ms is just None.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "hi", + "seq": 0 + }, + { + "role": "assistant", + "content": "hello", + "seq": 1 + }, + ] + assert _reconstruct(rows)[1].duration_ms is None + + +def test_reconstruct_rebuilds_plan_from_todo_write(): + """A persisted todo_list---todo_write becomes a `tasks` plan part (like the + live plan_updated event), not a dropped step; its result carries the merged + plan and statuses map to the frontend task states.""" + from app.backends.ms_agent.sessions import _reconstruct + + result = json.dumps({ + "status": + "ok", + "todos": [ + { + "content": "step one", + "id": "T1", + "status": "completed" + }, + { + "content": "step two", + "id": "T2", + "status": "in_progress" + }, + { + "content": "step three", + "id": "T3", + "status": "pending" + }, + ] + }) + rows = [ + { + "role": "user", + "content": "plan it", + "seq": 0 + }, + { + "role": + "assistant", + "content": + "working", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_write", + "arguments": '{"todos": []}' + }, + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": result, + "seq": 2 + }, + # A non-plan todo tool (render) is still dropped, not a step. + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c2", + "tool_name": "todo_list---todo_render_md", + "arguments": "{}" + }, + ], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "OK", + "seq": 4 + }, + { + "role": "assistant", + "content": "done", + "seq": 5 + }, + ] + msgs = _reconstruct(rows) + assert [m.role for m in msgs] == ["user", "assistant"] + parts = msgs[1].parts + # The tool-call row's real narration ("working") replays as a text part + # (only the SDK's 'Let me do a tool calling.' filler is dropped); the plan + # appears once, in stream order, before the final answer. + assert [p.kind for p in parts] == ["text", "tasks", "text"] + assert parts[0].text == "working" + tasks = parts[1].tasks + assert [(t.id, t.label, t.status) for t in tasks] == [ + ("0", "step one", "done"), + ("1", "step two", "running"), + ("2", "step three", "pending"), + ] + assert parts[2].text == "done" + + +def test_reconstruct_appends_plan_snapshot_per_write(): + """Each todo write appends its OWN `tasks` snapshot at that point in the + timeline (mirrors the live stream: the conversation shows the plan's state + per update; the composer's pinned panel is the live/merged view).""" + from app.backends.ms_agent.sessions import _reconstruct + + r1 = json.dumps({"todos": [{"content": "a", "status": "pending"}]}) + r2 = json.dumps({ + "todos": [{ + "content": "a", + "status": "completed" + }, { + "content": "b", + "status": "pending" + }] + }) + rows = [ + { + "role": "user", + "content": "go", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_write", + "arguments": "{}" + }, + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": r1, + "seq": 2 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c2", + "tool_name": "todo_list---todo_write", + "arguments": "{}" + }, + ], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": r2, + "seq": 4 + }, + ] + msgs = _reconstruct(rows) + assert [p.kind for p in msgs[1].parts] == ["tasks", "tasks"] + # First snapshot: the plan as it was at the first write. + assert [(t.label, t.status) for t in msgs[1].parts[0].tasks] == [ + ("a", "pending"), + ] + # Second snapshot: the updated plan (row a done, row b added). + assert [(t.label, t.status) for t in msgs[1].parts[1].tasks] == [ + ("a", "done"), + ("b", "pending"), + ] + + +class _FakeSession: + id = "sid-1" + name = "Session sid-1" # default-style name; autoname is a best-effort no-op here + + +class _FakeProject: + id = "pid-1" # done frames now carry project_id (for the new-session redirect) + + +class _FakeRuntime: + + def __init__(self): + self.sink = rt_mod.QueueEventSink() + self.turn_lock = asyncio.Lock() + self.input_queue: asyncio.Queue = asyncio.Queue() + self.run_task = _FakeRunTask() + self.watchers = 0 + + async def enqueue(self, text, marker=None): + # Mirrors SessionRuntime.enqueue: tuple only when a marker rides along. + await self.input_queue.put((text, marker) if marker else text) + + +class _FakeRunTask: + + def __init__(self, done: bool = False): + self._done = done + + def done(self): + return self._done + + +async def test_stream_maps_and_terminates(monkeypatch): + fake_rt = _FakeRuntime() + monkeypatch.setattr(chat, "_resolve_or_create", lambda req: + (_FakeProject(), _FakeSession())) + + async def _fake_get(project, session): + return fake_rt + + monkeypatch.setattr(chat.registry, "get", _fake_get) + + req = ChatRequest(session_id="sid-1", + message=ChatMessage(role="user", content="hi")) + frames: list[dict] = [] + + async def _consume(): + async for frame in chat.stream(req): + frames.append(frame) + + task = asyncio.create_task(_consume()) + + # The stream attaches its sink then enqueues the prompt; wait for that. + text = await asyncio.wait_for(fake_rt.input_queue.get(), timeout=2) + assert text == "hi" + + from ms_agent.ui.events import ( + ContentDelta, + ReasoningDelta, + ReasoningEnded, + ReasoningStarted, + ToolCallCompleted, + ToolCallStarted, + ) + + fake_rt.sink.emit(ContentDelta(text="Hello")) + fake_rt.sink.emit(ReasoningStarted()) + fake_rt.sink.emit(ReasoningDelta(text="thinking")) + fake_rt.sink.emit(ReasoningEnded()) + fake_rt.sink.emit( + ToolCallStarted(call_id="c1", name="tool", arguments={"a": 1})) + fake_rt.sink.emit(ToolCallCompleted(call_id="c1", name="tool", + result="ok")) + fake_rt.sink.push({"type": rt_mod.TURN_END}) # turn boundary -> done + + await asyncio.wait_for(task, timeout=2) + + parsed = [json.loads(f["data"]) for f in frames] + # An early `session` frame announces the created session before any content. + assert parsed[0]["type"] == "session" + assert parsed[0]["meta"]["session_id"] == "sid-1" + assert parsed[0]["meta"]["project_id"] == "pid-1" + body = parsed[1:] + # A `turn` frame follows, carrying the running turn's age so the client's + # "processing Ns" counter is based on the server clock. + assert body[0]["type"] == "turn" + assert isinstance(body[0]["meta"]["elapsed_ms"], int) + body = body[1:] + # Reasoning streams (delta frame) then finalizes (duration frame); the tool + # call emits a live "running" card on start, then its completed result (same + # call_id) which the frontend uses to replace the running card in place. + assert [p["type"] for p in body] == [ + "text", + "thought", + "thought", + "step", # running card (tool_call_started) + "step", # completed result (tool_call_completed) + "done", + ] + assert body[0]["content"] == "Hello" + assert body[1]["content"] == "thinking" + assert body[2]["content"] == "" and "duration" in body[2]["meta"] + assert body[3]["meta"]["kind"] == "tool_call" + assert body[3]["meta"]["status"] == "running" + assert body[3]["meta"]["call_id"] == "c1" + assert body[4]["meta"]["kind"] == "tool_call" + assert body[4]["meta"]["arguments"] == {"a": 1} + assert body[4]["meta"]["result"] == "ok" + assert body[4]["meta"]["call_id"] == "c1" + assert body[-1]["meta"]["session_id"] == "sid-1" + assert fake_rt.turn_lock.locked() is False + + +async def test_stream_done_carries_generated_title_and_category(monkeypatch): + """On a first message the concurrent titler result is folded into the `done` + frame so the frontend can refresh the lists with the summarized title + + topic category.""" + fake_rt = _FakeRuntime() + monkeypatch.setattr(chat, "_resolve_or_create", lambda req: + (_FakeProject(), _FakeSession())) + + async def _fake_get(project, session): + return fake_rt + + monkeypatch.setattr(chat.registry, "get", _fake_get) + + async def _fake_title(_text): + return ("My Title", "coding") + + monkeypatch.setattr(chat.titler, "generate_title_and_category", + _fake_title) + + req = ChatRequest(session_id="sid-1", + message=ChatMessage(role="user", content="write code")) + frames: list[dict] = [] + + async def _consume(): + async for frame in chat.stream(req): + frames.append(frame) + + task = asyncio.create_task(_consume()) + await asyncio.wait_for(fake_rt.input_queue.get(), timeout=2) + fake_rt.sink.push({"type": rt_mod.TURN_END}) + await asyncio.wait_for(task, timeout=2) + + done = json.loads(frames[-1]["data"]) + assert done["type"] == "done" + assert done["meta"]["title"] == "My Title" + assert done["meta"]["category"] == "coding" + assert done["meta"][ + "project_id"] == "pid-1" # drives the new-session redirect + + +async def test_stream_empty_user_message_ends_immediately(monkeypatch): + monkeypatch.setattr(chat, "_resolve_or_create", lambda req: + (_FakeProject(), _FakeSession())) + + async def _fake_get(project, session): # should not be reached + raise AssertionError("runtime must not be built for an empty prompt") + + monkeypatch.setattr(chat.registry, "get", _fake_get) + + req = ChatRequest(session_id="sid-1", + message=ChatMessage(role="user", content="")) + frames = [f async for f in chat.stream(req)] + assert len(frames) == 1 + assert json.loads(frames[0]["data"])["type"] == "done" + + +async def test_drain_abandoned_turn_keeps_running_then_releases_at_boundary(): + """Navigating away (no explicit stop) keeps the turn running in the + background: the drain does NOT discard the runtime; it waits (from the + leaver's cursor) for the turn boundary, then releases the lock. (Explicit + stop is a separate path — registry.interrupt.)""" + fake_rt = _FakeRuntime() + fake_rt.sink.new_turn() + fake_rt.sink.push({"type": "content_delta", "text": "partial"}) + await fake_rt.turn_lock.acquire() + + drain = asyncio.create_task( + chat._drain_abandoned_turn(fake_rt, fake_rt.sink.size, "sid-1")) + # Turn still in flight: the lock stays held (a next message to THIS session + # would wait) and the runtime is not discarded. + await asyncio.sleep(0) + assert fake_rt.turn_lock.locked() is True + # Reaching the turn boundary lets the drain release the lock and finish. + fake_rt.sink.push({"type": rt_mod.TURN_END}) + await asyncio.wait_for(drain, timeout=2) + assert fake_rt.turn_lock.locked() is False + + +async def test_attach_replays_buffer_then_follows_live_tail(monkeypatch): + """A late viewer gets a full catch-up of the in-flight turn (from the + sink's event buffer) and then the live tail until the boundary — the same + ChatChunk protocol as the original stream.""" + fake_rt = _FakeRuntime() + fake_rt.sink.new_turn() + # Events that happened BEFORE the viewer attached: + fake_rt.sink.push({"type": "reasoning_started"}) + fake_rt.sink.push({"type": "reasoning_delta", "text": "想一想"}) + fake_rt.sink.push({"type": "reasoning_ended"}) + fake_rt.sink.push({"type": "content_delta", "text": "前半段"}) + await fake_rt.turn_lock.acquire() # turn in flight + + monkeypatch.setattr(chat.registry, "peek", lambda sid: fake_rt) + monkeypatch.setattr(chat.registry, "is_running", lambda sid: True) + + frames: list[dict] = [] + + async def _consume(): + async for f in chat.attach("sid-9"): + frames.append(json.loads(f["data"])) + + task = asyncio.create_task(_consume()) + await asyncio.sleep(0.05) # catch-up should have flowed already + kinds = [f["type"] for f in frames] + # The rejoining client is told the turn's age FIRST (so its counter + # continues rather than restarting), then the buffer replays. + assert kinds == ["turn", "thought", "thought", "text"] + assert isinstance(frames[0]["meta"]["elapsed_ms"], int) + assert frames[2]["meta"].get("duration") is not None + assert fake_rt.watchers == 1 # the attached viewer is counted + + # Live tail after attach: + fake_rt.sink.push({"type": "content_delta", "text": "后半段"}) + fake_rt.sink.push({"type": rt_mod.TURN_END}) + await asyncio.wait_for(task, timeout=2) + kinds = [f["type"] for f in frames] + assert kinds == ["turn", "thought", "thought", "text", "text", "done"] + assert frames[4]["content"] == "后半段" + assert fake_rt.watchers == 0 + + +async def test_attach_idle_session_yields_done(monkeypatch): + monkeypatch.setattr(chat.registry, "peek", lambda sid: None) + frames = [json.loads(f["data"]) async for f in chat.attach("sid-x")] + assert [f["type"] for f in frames] == ["done"] + + +async def test_registry_interrupt_discards_and_seals(): + """Explicit stop discards the runtime (cancelling generation) AND seals a + dangling turn so the rebuilt agent answers the NEXT message.""" + registry = rt_mod.RuntimeRegistry() + registry._loop = asyncio.get_running_loop() + + log = _FakeLog([{"role": "user", "content": "写长文"}]) + + class _Rt: + + def __init__(self): + self.closed = False + self.run_task = _FakeRunTask() + self.agent = type("A", (), {"session_log": log})() + + async def aclose(self): + self.closed = True + + rt = _Rt() + registry._runtimes["sid-x"] = rt + + assert await registry.interrupt("sid-x") is True + assert rt.closed is True # runtime discarded + assert "sid-x" not in registry._runtimes + tail = log.get_all_messages()[-1] + assert tail["role"] == "assistant" and tail["interrupted"] is True + assert tail["content"] == "[interrupted]" # neutral marker, not a localized sentinel + + +async def test_registry_interrupt_noop_without_live_runtime(): + registry = rt_mod.RuntimeRegistry() + assert await registry.interrupt("nope") is False + + +async def test_registry_running_state(): + registry = rt_mod.RuntimeRegistry() + rt = _FakeRuntime() + registry._runtimes["sid-r"] = rt + + # Idle: lock free -> not running. + assert registry.is_running("sid-r") is False + await rt.turn_lock.acquire() + assert registry.is_running("sid-r") is True + assert registry.running_sessions() == ["sid-r"] + assert registry.is_running("nope") is False + + +class _FakeLog: + + def __init__(self, messages): + self._messages = messages + + def get_all_messages(self): + return self._messages + + def append(self, message): + self._messages.append(message) + return len(self._messages) + + +class _FakeAgentRt: + + def __init__(self, messages): + self.agent = type("A", (), {"session_log": _FakeLog(messages)})() + + +def test_seal_interrupted_turn_closes_dangling_user(): + # Fallback only: the SDK normally seals the round itself; a dangling user + # tail means that persistence never ran, so close it with the marker row. + rt = _FakeAgentRt([{"role": "user", "content": "写长文"}]) + chat._seal_interrupted_turn(rt) + tail = rt.agent.session_log.get_all_messages()[-1] + assert tail == { + "role": "assistant", + "content": "[interrupted]", + "content_placeholder": True, + "interrupted": True, + } + + +def test_seal_interrupted_turn_closes_dangling_tool(): + # Aborted after a tool round: last row is a tool result awaiting a reply. + rt = _FakeAgentRt([ + { + "role": "user", + "content": "跑一下" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "c1" + }] + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "ok" + }, + ]) + chat._seal_interrupted_turn(rt) + assert rt.agent.session_log.get_all_messages()[-1]["role"] == "assistant" + + +def test_seal_interrupted_turn_noop_when_already_answered(): + # A completed turn ends on an assistant answer: nothing to seal. + msgs = [{ + "role": "user", + "content": "hi" + }, { + "role": "assistant", + "content": "答案" + }] + rt = _FakeAgentRt(list(msgs)) + chat._seal_interrupted_turn(rt) + assert rt.agent.session_log.get_all_messages() == msgs + + +def test_reconstruct_hides_placeholder_by_flag_not_string(): + """An interrupted seal row is identified by the structured + ``content_placeholder`` flag (not by matching a literal): its content is + hidden and the interrupted badge renders, whatever the placeholder text.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + {"role": "user", "content": "写长文", "seq": 0}, + {"role": "assistant", "content": "<>", + "content_placeholder": True, "interrupted": True, "seq": 1}, + ] + parts = _reconstruct(rows)[1].parts + assert not any(p.kind == "text" for p in parts) # placeholder not shown + assert any(p.kind == "interrupted" for p in parts) # badge shown + + +def test_placeholder_detection_is_narrow_and_shape_safe(): + """`is_placeholder_content` must not over- or under-reach: + + - list (multimodal) content never raises and is never filler; + - a GENUINE reply equal to a literal renders (no corroborating structure); + - the flag wins on any row shape, including a tool_calls row. + """ + from app.backends.ms_agent.chat import is_placeholder_content + + # multimodal content: no TypeError from an unhashable value, not filler + assert is_placeholder_content( + {"role": "assistant", "content": [{"type": "text", "text": "hi"}]} + ) is False + # genuine replies that happen to equal a literal: corroboration absent + assert is_placeholder_content( + {"role": "assistant", "content": "[interrupted]"}) is False + assert is_placeholder_content( + {"role": "assistant", "content": "Let me do a tool calling."}) is False + # old-log fillers: literal + corroborating structure + assert is_placeholder_content({ + "role": "assistant", "content": "[interrupted]", "interrupted": True + }) is True + assert is_placeholder_content({ + "role": "assistant", "content": "Let me do a tool calling.", + "tool_calls": [{"id": "c1"}], + }) is True + # the structured flag is authoritative on any shape (incl. tool_calls rows) + assert is_placeholder_content({ + "role": "assistant", "content": "whatever", + "content_placeholder": True, "tool_calls": [{"id": "c1"}], + }) is True + + +def test_reconstruct_survives_multimodal_assistant_content(): + """A list-content assistant row must not crash history rebuild (a raw + ``content in {...}`` check would TypeError and 500 the whole session).""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + {"role": "user", "content": "看图", "seq": 0}, + {"role": "assistant", + "content": [{"type": "text", "text": "图里是一只猫"}], "seq": 1}, + ] + parts = _reconstruct(rows)[1].parts + assert any(p.kind == "text" for p in parts) # rendered, not dropped + + +def test_reconstruct_keeps_genuine_reply_equal_to_literal(): + """A real answer whose text happens to equal a placeholder literal is NOT + hidden (the literal fallback requires corroborating structure).""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + {"role": "user", "content": "复述这句:[interrupted]", "seq": 0}, + {"role": "assistant", "content": "[interrupted]", "seq": 1}, + ] + parts = _reconstruct(rows)[1].parts + assert [p.text for p in parts if p.kind == "text"] == ["[interrupted]"] + + +def test_reconstruct_replays_interrupted_reasoning_duration(): + """A turn interrupted mid-thinking persists its partial reasoning under + ``interrupted_reasoning`` plus ``reasoning_duration`` — replay shows the + thought block with that elapsed time (not 0s).""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + {"role": "user", "content": "想个方案", "seq": 0}, + {"role": "assistant", "content": "[interrupted]", + "content_placeholder": True, "interrupted": True, + "interrupted_reasoning": "正在权衡…", "reasoning_duration": 9, + "seq": 1}, + ] + parts = _reconstruct(rows)[1].parts + thought = next(p for p in parts if p.kind == "thought") + assert thought.text == "正在权衡…" + assert thought.duration == 9 # not 0s + + +def test_reconstruct_interrupted_partial_text(): + """A faithfully-sealed mid-text interrupt replays its partial content + verbatim plus an `interrupted` badge part — no fabricated text.""" + from app.backends.ms_agent.sessions import _reconstruct + + msgs = _reconstruct([ + { + "role": "user", + "content": "写篇长文", + "seq": 0 + }, + { + "role": "assistant", + "content": "写到一半的回答", + "interrupted": True, + "seq": 1 + }, + ]) + assert [m.role for m in msgs] == ["user", "assistant"] + assert msgs[1].content == "写到一半的回答" + assert [p.kind for p in msgs[1].parts] == ["text", "interrupted"] + + +def test_reconstruct_interrupted_reasoning_and_placeholder(): + """Unsigned partial reasoning replays as a thought; the neutral + `[interrupted]` placeholder is never rendered as text.""" + from app.backends.ms_agent.sessions import _reconstruct + + msgs = _reconstruct([ + { + "role": "user", + "content": "推理题", + "seq": 0 + }, + { + "role": "assistant", + "content": "[interrupted]", + "interrupted_reasoning": "思考到一半…", + "interrupted": True, + "seq": 1 + }, + ]) + parts = msgs[1].parts + assert [p.kind for p in parts] == ["thought", "interrupted"] + assert parts[0].text == "思考到一半…" + assert msgs[1].content == "" # placeholder suppressed + + +def test_reconstruct_interrupted_mid_tools_marks_steps(): + """SDK-synthesized interrupted tool results mark their steps errored via + the existing is_error path; the badge closes the turn.""" + from app.backends.ms_agent.sessions import _reconstruct + + msgs = _reconstruct([ + { + "role": "user", + "content": "跑工具", + "seq": 0 + }, + { + "role": + "assistant", + "content": + "", + "interrupted": + True, + "tool_calls": [{ + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": '{"path": "a.md"}' + }], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "[Interrupted: tool execution was cancelled]", + "is_error": True, + "interrupted": True, + "seq": 2 + }, + ]) + parts = msgs[1].parts + assert [p.kind for p in parts] == ["step", "interrupted"] + step = parts[0].step + # Keeps its file kind — see test_reconstruct_surfaces_errors_and_failed_steps. + assert step.kind == "file_write" and step.meta["status"] == "error" + assert "Interrupted" in step.meta["error"] + + +def test_read_plan_prefers_session_scope_with_legacy_fallback( + monkeypatch, tmp_path): + """Plans are session-scoped (/plan.json); sessions from before + the isolation change fall back to the project-shared plan.json.""" + import json as _json + + from app.backends.ms_agent import sessions + + proj_dir = tmp_path / "proj" + sess_dir = tmp_path / "sessions" / "sid-1" + proj_dir.mkdir() + sess_dir.mkdir(parents=True) + + project = type("P", (), {"path": str(proj_dir)})() + session = type("S", (), {"id": "sid-1"})() + monkeypatch.setattr(sessions, "find_session", lambda sid: + (project, session, object())) + monkeypatch.setattr("app.backends.ms_agent.config.session_dir", + lambda p, s: str(sess_dir)) + + # Legacy project plan only -> fallback is used. (read_plan now returns a + # SessionPlan: tasks + the "written during the running turn" flag; no + # runtime here, so active stays False.) + (proj_dir / "plan.json").write_text( + _json.dumps({"todos": [{ + "content": "legacy", + "status": "pending" + }]})) + plan = sessions.read_plan("sid-1") + assert [t.label for t in plan.tasks] == ["legacy"] + assert plan.active is False + + # Session-scoped plan appears -> it wins over the legacy file. + (sess_dir / "plan.json").write_text( + _json.dumps({"todos": [{ + "content": "mine", + "status": "in_progress" + }]})) + plan = sessions.read_plan("sid-1") + assert [(t.label, t.status) for t in plan.tasks] == [("mine", "running")] + + +def test_reconstruct_placeholder_only_interrupt_kept(): + """A cancel-before-first-chunk seal (placeholder-only row) still yields a + visible assistant message carrying just the badge.""" + from app.backends.ms_agent.sessions import _reconstruct + + msgs = _reconstruct([ + { + "role": "user", + "content": "hi", + "seq": 0 + }, + { + "role": "assistant", + "content": "[interrupted]", + "interrupted": True, + "seq": 1 + }, + ]) + assert [m.role for m in msgs] == ["user", "assistant"] + assert [p.kind for p in msgs[1].parts] == ["interrupted"] + assert msgs[1].content == "" + + +async def test_registry_discard_closes_runtime_from_threadpool(): + registry = rt_mod.RuntimeRegistry() + registry._loop = asyncio.get_running_loop() + + class _ClosableRuntime: + + def __init__(self): + self.closed = False + self.run_task = _FakeRunTask() + + async def aclose(self): + self.closed = True + + fake_rt = _ClosableRuntime() + registry._runtimes["sid-thread"] = fake_rt + + await asyncio.to_thread(registry.discard, "sid-thread") + + assert fake_rt.closed is True + assert "sid-thread" not in registry._runtimes + + +def test_list_artifacts_ledger_from_session_log(monkeypatch, tmp_path): + """The per-conversation artifact ledger is derived from the immutable + SessionLog tool-calls: file_system write/edit only, deduped by path in + first-write order, reads excluded, and a file gone from disk is kept as + deleted (history is not erased by later user actions).""" + from app.backends.ms_agent import sessions + + rows = [ + { + "role": "user", + "content": "make files", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---write_file", + "arguments": '{"path": "a.md", "content": "x"}' + }, + { + "id": "c2", + "tool_name": "file_system---write_file", + "arguments": '{"path": "sub/b.txt", "content": "yy"}' + }, + ], + "seq": + 1 + }, + { + "role": + "assistant", + "tool_calls": [ + # edit of a.md -> deduped (not a second entry) + { + "id": + "c3", + "tool_name": + "file_system---edit_file", + "arguments": + '{"path": "a.md", "old_string": "x", "new_string": "z"}' + }, + # a read -> excluded from the ledger + { + "id": "c4", + "tool_name": "file_system---read_file", + "arguments": '{"path": "a.md"}' + }, + # written but later deleted by the user -> kept, marked deleted + { + "id": "c5", + "tool_name": "file_system---write_file", + "arguments": '{"path": "gone.md", "content": "d"}' + }, + ], + "seq": + 2 + }, + ] + (tmp_path / "a.md").write_text("z") # 1 byte + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.txt").write_text("yy") # 2 bytes + # gone.md intentionally absent on disk + + project = type("P", (), {"path": str(tmp_path)})() + sm = type("SM", (), + {"get_session_log": staticmethod(lambda s: _FakeLog(rows))})() + monkeypatch.setattr(sessions, "find_session", lambda sid: + (project, _FakeSession(), sm)) + + arts = sessions.list_artifacts("sid-1") + assert [a.path for a in arts] == ["a.md", "sub/b.txt", + "gone.md"] # order + dedup + by = {a.path: a for a in arts} + assert by["a.md"].deleted is False and by["a.md"].size == 1 and by[ + "a.md"].name == "a.md" + assert by["sub/b.txt"].name == "b.txt" and by["sub/b.txt"].size == 2 + assert by["gone.md"].deleted is True and by["gone.md"].size == 0 + assert by["a.md"].id == sessions._artifact_id( + "a.md") # stable, path-derived + + +def test_list_artifacts_excludes_plan_and_out_of_workspace( + monkeypatch, tmp_path): + """The composer's file list (artifact ledger) must not show plan files or + files written outside the workspace: the model copying its plan into the + session dir, or rendering it under a custom name, are session/plan state — + only genuine in-workspace deliverables are listed.""" + from app.backends.ms_agent import sessions + + rows = [ + { + "role": "user", + "content": "plan then build", + "seq": 0 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c1", + "tool_name": "todo_list---todo_render_md", + "arguments": '{"path": "my_plan.md"}' + }, + ], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "OK: rendered plan markdown to my_plan.md", + "seq": 2 + }, + { + "role": + "assistant", + "tool_calls": [ + { + "id": "c2", + "tool_name": "file_system---write_file", + "arguments": '{"path": "out.txt", "content": "x"}' + }, + # plan copied into the session dir (outside the workspace) + { + "id": "c3", + "tool_name": "file_system---write_file", + "arguments": + '{"path": "../sess/plan_copy.md", "content": "p"}' + }, + # rewrite of the custom-named plan the render produced -> excluded + { + "id": + "c4", + "tool_name": + "file_system---edit_file", + "arguments": + '{"path": "my_plan.md", "old_string": "a", ' + '"new_string": "b"}' + }, + ], + "seq": + 3 + }, + ] + (tmp_path / "out.txt").write_text("x") + (tmp_path / "my_plan.md").write_text( + "# plan") # exists, but is a plan file + + project = type("P", (), {"path": str(tmp_path)})() + sm = type("SM", (), + {"get_session_log": staticmethod(lambda s: _FakeLog(rows))})() + monkeypatch.setattr(sessions, "find_session", lambda sid: + (project, _FakeSession(), sm)) + + arts = sessions.list_artifacts("sid-1") + paths = [a.path for a in arts] + assert paths == ["out.txt"] # only the genuine deliverable + + +class _FakeSkill: + + def __init__(self, skill_id, name, content): + self.skill_id = skill_id + self.name = name + self.description = f"{name} desc" + self.content = content + self.skill_path = f"/skills/{skill_id}" + + +class _FakeCatalog: + + def __init__(self, *skills): + self._skills = {s.skill_id: s for s in skills} + + def get_skill(self, name): + return self._skills.get(name) + + +class _CatalogRt: + + def __init__(self, catalog): + self.agent = type("A", (), {"_skill_catalog": catalog})() + + +def test_expand_skill_request_two_tiers(): + """Tier 1 (structured ids) and tier 2 (anywhere-in-text token) both expand; + unknown skills / no catalog fall through as plain text.""" + cat = _FakeCatalog( + _FakeSkill("writer", "Writer", + "---\nname: Writer\n---\nGuide: $ARGUMENTS")) + rt = _CatalogRt(cat) + + # Tier 1: structured id, /token anywhere in the text; args = text - token. + kind, content, marker = chat._expand_skill_request(rt, "帮我 /Writer 改写这段", + ["writer"]) + assert kind == "submit" + assert "Guide: 帮我 改写这段" in content # $ARGUMENTS filled, token gone + assert marker == { + "original_text": "帮我 /Writer 改写这段", + "skill_ids": ["writer"] + } + + # Tier 2: no structured ids — first known /token found mid-text wins. + kind, content, marker = chat._expand_skill_request(rt, "开始 /writer 润色", []) + assert kind == "submit" and "User's request: 开始 润色" in content + assert marker["skill_ids"] == ["writer"] + + # No-arg invocation -> the skill body submits too (the model reads it and + # acts); the tail line flags the missing input, marker keeps the slash form. + kind, content, marker = chat._expand_skill_request(rt, "/writer", []) + assert kind == "submit" + assert "Use the [Writer] skill" in content + assert "without additional arguments" in content + assert marker == {"original_text": "/writer", "skill_ids": ["writer"]} + + # Composer skill pick with no typed text: empty prompt, structured id only. + # The marker falls back to the slash form for a readable replay bubble. + kind, content, marker = chat._expand_skill_request(rt, "", ["writer"]) + assert kind == "submit" and "without additional arguments" in content + assert marker == {"original_text": "/writer", "skill_ids": ["writer"]} + + # Unknown token / unknown structured id / no catalog -> passthrough. + assert chat._expand_skill_request(rt, "看看 /nope 是什么", []) is None + assert chat._expand_skill_request(rt, "普通消息", ["nope"]) is None + assert chat._expand_skill_request( + type("R", (), {"agent": None})(), "/x", []) is None + # Mid-word slashes never trigger. + assert chat._expand_skill_request(rt, "路径 a/writer 之类", []) is None + + +async def test_stream_message_kind_short_circuits_without_a_turn(monkeypatch): + """The "message" expansion kind (kept as a fallback for non-submit + CommandResult types — bare /skill now submits) answers directly: one text + frame, no enqueued turn, lock released.""" + fake_rt = _FakeRuntime() + monkeypatch.setattr(chat, "_resolve_or_create", lambda req: + (_FakeProject(), _FakeSession())) + + async def _fake_get(project, session): + return fake_rt + + monkeypatch.setattr(chat.registry, "get", _fake_get) + monkeypatch.setattr( + chat, "_expand_skill_request", lambda rt, prompt, ids: + ("message", "Skill: Writer", None)) + + req = ChatRequest(session_id="sid-1", + message=ChatMessage(role="user", content="/writer")) + frames = [json.loads(f["data"]) async for f in chat.stream(req)] + # The early `session` frame precedes the reply text + terminator. + assert [f["type"] for f in frames] == ["session", "text", "done"] + assert frames[1]["content"] == "Skill: Writer" + # The turn lock is taken for the sync+expand window but released for a + # direct reply — and no turn was enqueued. + assert not fake_rt.turn_lock.locked() + assert fake_rt.input_queue.empty() + assert fake_rt.watchers == 0 + + +async def test_stream_slash_submit_enqueues_expanded_prompt_with_marker( + monkeypatch): + fake_rt = _FakeRuntime() + monkeypatch.setattr(chat, "_resolve_or_create", lambda req: + (_FakeProject(), _FakeSession())) + + async def _fake_get(project, session): + return fake_rt + + monkeypatch.setattr(chat.registry, "get", _fake_get) + the_marker = {"original_text": "/writer go", "skill_ids": ["writer"]} + monkeypatch.setattr( + chat, "_expand_skill_request", lambda rt, prompt, ids: + ("submit", "ENRICHED PROMPT", the_marker)) + + req = ChatRequest(session_id="sid-1", + message=ChatMessage(role="user", + content="/writer go", + skills=["writer"])) + frames: list[dict] = [] + + async def _consume(): + async for f in chat.stream(req): + frames.append(f) + + task = asyncio.create_task(_consume()) + # The expanded prompt (not the raw "/writer go") is what the agent runs, + # and the display marker rides alongside for the input source to persist. + item = await asyncio.wait_for(fake_rt.input_queue.get(), timeout=2) + assert item == ("ENRICHED PROMPT", the_marker) + fake_rt.sink.push({"type": rt_mod.TURN_END}) + await asyncio.wait_for(task, timeout=2) + + +def test_reconstruct_skill_invocation_shows_original_text(): + """History replay shows the user's typed text (from the display marker) + instead of the expanded skill prompt persisted as the user row.""" + from app.backends.ms_agent.sessions import _reconstruct + + msgs = _reconstruct([ + { + "_type": "skill_invocation", + "original_text": "/writer 润色这段", + "skill_ids": ["writer"], + "seq": 0 + }, + { + "role": "user", + "content": "Use the [Writer] skill located at …", + "seq": 1 + }, + { + "role": "assistant", + "content": "润色好了", + "seq": 2 + }, + ]) + assert [m.role for m in msgs] == ["user", "assistant"] + assert msgs[0].content == "/writer 润色这段" # original, not the wrapper + assert msgs[1].content == "润色好了" + + +def _boom(msg): + + async def _fail(*a, **k): + raise AssertionError(msg) + + return _fail + + +def test_sync_runtime_skills_picks_up_live_tree_drop(tmp_path): + """Turn-boundary sync: a skill dropped into the project live tree after the + agent was built becomes visible to the live catalog (and thus to slash + expansion + the next system prompt).""" + from omegaconf import OmegaConf + + from ms_agent.skill.catalog import SkillCatalog + from ms_agent.skill.runtime import SkillRuntime + from ms_agent.tui.managed_config import merge_skills_into_config + from app.backends.ms_agent.common import home + + proj_dir = tmp_path / "proj" + proj_dir.mkdir() + project = _FakeProject() + project.path = str(proj_dir) + + cfg = OmegaConf.create({}) + merge_skills_into_config(cfg, home(), + project.path) # baked at build: empty + catalog = SkillCatalog(config=cfg.get("skills")) + if cfg.get("skills"): + catalog.load_from_config(cfg.skills) + skill_runtime = SkillRuntime(catalog=catalog) + agent = type("A", (), {"config": cfg, "_skill_runtime": skill_runtime})() + rt = type("RT", (), {"agent": agent})() + + # Mid-session: drop a skill into the project live tree, no skills.json edit. + tree = proj_dir / ".ms_agent" / "skills" / "late-skill" + tree.mkdir(parents=True) + (tree / "SKILL.md").write_text( + "---\nname: late-skill\ndescription: added mid-session\n---\n# L\n", + encoding="utf-8", + ) + + assert catalog.get_skill("late-skill") is None + chat._sync_runtime_skills(rt, project) + assert catalog.get_skill("late-skill") is not None + assert skill_runtime.needs_refresh( + ) # next round rebuilds the system prompt + + +def test_resolve_permission_forwards_allow_always_to_the_handler(): + """The three-way authorization card (deny / always allow / allow once) maps + each action straight onto the SDK's PermissionAction — notably allow_always, + which the enforcer persists so later identical calls skip the ask.""" + from ms_agent.permission.handler import PermissionAction + + seen: list[PermissionAction] = [] + + class _Handler: + + def __init__(self) -> None: + fut: asyncio.Future = asyncio.get_event_loop().create_future() + self._pending = {"req-1": fut} + + def resolve(self, request_id, response): + seen.append(response.action) + self._pending[request_id].set_result(response) + + async def drive(): + registry = rt_mod.RuntimeRegistry() + registry._runtimes["s1"] = type("RT", (), + {"permission_handler": _Handler()})() + + assert registry.resolve_permission("s1", "req-1", + "allow_always") is True + assert seen == [PermissionAction.ALLOW_ALWAYS] + # Already resolved / unknown request ids are rejected, not re-answered. + assert registry.resolve_permission("s1", "req-1", + "allow_once") is False + assert registry.resolve_permission("s1", "nope", "deny") is False + # An action outside the SDK enum must not resolve the turn. + registry._runtimes["s2"] = type("RT", (), + {"permission_handler": _Handler()})() + assert registry.resolve_permission("s2", "req-1", + "allow_forever") is False + + asyncio.run(drive()) + + +def test_reconstruct_replays_tool_row_narration_but_filters_placeholder(): + """Real mid-turn narration on a tool_calls row replays verbatim, but the + framework's 'Let me do a tool calling.' placeholder (SDK filler for a + content-less tool-call turn, present in OLD logs) is filtered — it was never + model output.""" + from app.backends.ms_agent.sessions import _reconstruct + + rows = [ + { + "role": "user", + "content": "go", + "seq": 0 + }, + { + "role": + "assistant", + "content": + "I'll check the workspace first.", + "tool_calls": [{ + "id": "c1", + "tool_name": "file_system---glob", + "arguments": "{}" + }], + "seq": + 1 + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": "[]", + "seq": 2 + }, + { + "role": + "assistant", + "content": + "Let me do a tool calling.", + "tool_calls": [{ + "id": "c2", + "tool_name": "file_system---grep", + "arguments": "{}" + }], + "seq": + 3 + }, + { + "role": "tool", + "tool_call_id": "c2", + "content": "[]", + "seq": 4 + }, + { + "role": "assistant", + "content": "all done", + "seq": 5 + }, + ] + msgs = _reconstruct(rows) + parts = msgs[1].parts + # The placeholder row yields NO text part (filtered), so the two tool steps + # sit adjacent between the real narration and the final summary. + assert [p.kind for p in parts] == ["text", "step", "step", "text"] + assert parts[0].text == "I'll check the workspace first." # real narration + assert parts[-1].text == "all done" # summary + # The framework placeholder never surfaces as text. + assert all(p.text != "Let me do a tool calling." for p in parts + if p.kind == "text") + + +def test_seal_interrupted_turn_records_loop_end_duration(): + """A cancelled turn never reaches loop_end, so without this the replayed + "processing Ns" header had no duration to show and rendered 0s. Sealing an + interrupt records the boundary with the elapsed time up to the stop.""" + import time as _time + + class _LogWithLoopEnd(_FakeLog): + + def __init__(self, messages): + super().__init__(messages) + self.loop_ends: list[dict] = [] + + def record_loop_end(self, event): + self.loop_ends.append(event) + + log = _LogWithLoopEnd([ + {"role": "user", "content": "写长文"}, + # Partial answer the SDK persisted when the turn was cancelled. + {"role": "assistant", "content": "开头…", "interrupted": True}, + ]) + rt = type("RT", (), {})() + rt.agent = type("A", (), {"session_log": log})() + rt.session = type("S", (), {"id": "s1"})() + rt.project = None + # Turn started ~2s ago on the monotonic clock the seal reads. + rt.turn_started_at = _time.monotonic() - 2.0 + + chat._seal_interrupted_turn(rt) + + assert len(log.loop_ends) == 1, "interrupt must record exactly one loop_end" + duration = log.loop_ends[0]["duration_ms"] + assert 1500 <= duration <= 4000, f"elapsed not carried over: {duration}" + # The turn already ended on an assistant row → no extra placeholder row. + assert log.get_all_messages()[-1]["content"] == "开头…" + + +def test_persist_loop_end_is_idempotent_per_turn(): + """One Stop drives BOTH the interrupt seal and the aborted-SSE drain, each + calling _persist_loop_end_from_log for the same turn. Only ONE loop_end may + be written (the 'at most one loop_end per turn' invariant).""" + import time as _t + + class _Log: + def __init__(self): + self._all: list[dict] = [] + self._seq = 0 + + def _next(self): + s = self._seq + self._seq += 1 + return s + + def get_all_messages(self): + return [r for r in self._all if r.get("_type") is None] + + def append(self, rec): + rec = {**rec, "seq": self._next()} + self._all.append(rec) + return rec["seq"] + + def record_loop_end(self, payload): + self._all.append({"_type": "loop_end", "seq": self._next(), **payload}) + + def get_loop_ends(self): + return [r for r in self._all if r.get("_type") == "loop_end"] + + log = _Log() + log.append({"role": "user", "content": "写长文"}) + log.append({"role": "assistant", "content": "开头…", "interrupted": True}) + rt = type("RT", (), {})() + rt.agent = type("A", (), {"session_log": log})() + rt.session = type("S", (), {"id": "s1"})() + rt.project = None + rt.turn_started_at = _t.monotonic() - 1.0 + + chat._persist_loop_end_from_log(rt, "s1") # e.g. the interrupt seal + chat._persist_loop_end_from_log(rt, "s1") # e.g. the aborted-SSE drain + + assert len(log.get_loop_ends()) == 1, "duplicate loop_end for one turn" + + +def test_interrupt_seals_while_holding_create_lock_and_removes_runtime(): + """The stop (pop + aclose + seal) runs entirely under _create_lock so a + concurrent get() cannot build a second runtime on the same SessionLog while + the interrupted turn is still being sealed (which lost the turn's history).""" + reg = rt_mod.RuntimeRegistry() + + class _RT: + async def aclose(self): + pass + + reg._runtimes["s1"] = _RT() + + seen: dict = {} + orig_seal = chat._seal_interrupted_turn + + def _spy(_rt): + # interrupt() re-imports this from the chat module at call time, so the + # patch is picked up; record whether the create lock is held right now. + seen["locked_during_seal"] = reg._create_lock.locked() + + chat._seal_interrupted_turn = _spy + try: + stopped = asyncio.run(reg.interrupt("s1")) + finally: + chat._seal_interrupted_turn = orig_seal + + assert stopped is True + assert seen.get("locked_during_seal") is True # seal ran with the lock held + assert "s1" not in reg._runtimes # runtime removed by the stop + + +def test_history_step_multi_file_exists_checks_each_path(tmp_path): + """A multi-file read persists `paths: [...]` and a comma-joined display + `path`. Existence must be judged per-file (ALL present ⇒ exists), never by + matching the joined string — which would false-flag every multi-read as a + deleted file.""" + from app.backends.ms_agent.sessions import _history_step + + (tmp_path / "Dockerfile").write_text("x", encoding="utf-8") + (tmp_path / "docker-compose.yml").write_text("y", encoding="utf-8") + project = type("P", (), {"path": str(tmp_path)})() + + tc = { + "id": "c1", + "tool_name": "file_system---read_file", + "arguments": json.dumps( + {"paths": ["Dockerfile", "docker-compose.yml"]} + ), + } + step = _history_step(tc, {}, {}, {}, project) + assert step is not None + assert step.meta["paths"] == ["Dockerfile", "docker-compose.yml"] + # Both files present → exists True (not a false "deleted"). + assert step.meta["exists"] is True + assert step.meta["path"] == "Dockerfile, docker-compose.yml" + + # One missing → the whole multi-step reads as gone. + tc2 = { + "id": "c2", + "tool_name": "file_system---read_file", + "arguments": json.dumps({"paths": ["Dockerfile", "gone.txt"]}), + } + step2 = _history_step(tc2, {}, {}, {}, project) + assert step2.meta["exists"] is False + + +def test_resolve_or_create_touches_existing_session_updated_at(monkeypatch): + """Reusing a session bumps its ``updated_at``. + + Session lists are ordered by that field, and the SDK only refreshes it + inside ``SessionManager.update()`` -- appending conversation rows goes + through SessionLog and leaves the meta file alone. Without the explicit + touch the timestamp recorded when the title was generated, so an actively + used conversation never rose to the top of the sidebar. + """ + touched: list[tuple[str, tuple]] = [] + + class _SM: + def update(self, session_id, **kwargs): + touched.append((session_id, tuple(sorted(kwargs)))) + + session = _FakeSession() + project = _FakeProject() + + monkeypatch.setattr(chat, "find_session", + lambda sid: (project, session, _SM())) + monkeypatch.setattr(chat, "sm_for", lambda proj: _SM()) + + got_project, got_session = chat._resolve_or_create( + ChatRequest(session_id="sid-1", project_id=None, + message=ChatMessage(role="user", content="hi"))) + + assert got_project is project and got_session is session + # Touched exactly once, with NO field kwargs: the call must only move the + # timestamp, never overwrite the name/status. + assert touched == [("sid-1", ())] + + +def test_touch_session_never_raises_when_update_fails(monkeypatch): + """Ordering is cosmetic -- a failing touch must not break the turn.""" + + class _Boom: + def update(self, *_a, **_k): + raise RuntimeError("disk full") + + monkeypatch.setattr(chat, "sm_for", lambda proj: _Boom()) + chat._touch_session(_FakeProject(), "sid-1") # must not raise + + +def test_permission_handler_announces_a_denial_to_live_viewers(monkeypatch): + """A refusal must reach the stream, not just the session log. The SDK's ask() + resolves to DENY silently on timeout, so the runtime's wrapper pushes a + `permission_resolved` event — without it a viewer's card kept offering buttons + for a request that was already dead.""" + import asyncio + + from ms_agent.permission.handler import ( + PermissionAction, + PermissionResponse, + WebPermissionHandler, + ) + + from app.backends.ms_agent import runtime as runtime_mod + + pushed: list[dict] = [] + + class _Sink: + def push(self, payload: dict) -> None: + pushed.append(payload) + + async def _timed_out(self, tool_name, tool_args, context, suggestions=None, + call_id=""): + return PermissionResponse(action=PermissionAction.DENY, + feedback="Permission request timed out") + + monkeypatch.setattr(WebPermissionHandler, "ask", _timed_out) + handler = runtime_mod._persisting_permission_handler(_Sink(), lambda: None) + asyncio.run( + handler.ask("code_executor---shell_executor", {"command": "echo aaa"}, + "", call_id="c1")) + + assert [e["type"] for e in pushed] == ["permission_resolved"] + assert pushed[0]["state"] == "rejected" + assert pushed[0]["call_id"] == "c1" + assert pushed[0]["tool_name"] == "code_executor---shell_executor" + + +def test_permission_handler_stays_silent_on_approval(monkeypatch): + """An APPROVAL needs no announcement: the deciding client already flipped its + card, other viewers get the resolved ask on attach, and the tool's result + lands next. Emitting one would replace the live card with a request_id-less + copy and drop its "executing" state.""" + import asyncio + + from ms_agent.permission.handler import ( + PermissionAction, + PermissionResponse, + WebPermissionHandler, + ) + + from app.backends.ms_agent import runtime as runtime_mod + + pushed: list[dict] = [] + + class _Sink: + def push(self, payload: dict) -> None: + pushed.append(payload) + + async def _allow(self, tool_name, tool_args, context, suggestions=None, + call_id=""): + return PermissionResponse(action=PermissionAction.ALLOW_ONCE) + + monkeypatch.setattr(WebPermissionHandler, "ask", _allow) + handler = runtime_mod._persisting_permission_handler(_Sink(), lambda: None) + asyncio.run( + handler.ask("code_executor---shell_executor", {"command": "echo aaa"}, + "", call_id="c1")) + assert pushed == [] diff --git a/webui/backend/tests/test_config.py b/webui/backend/tests/test_config.py new file mode 100644 index 000000000..059b71a8d --- /dev/null +++ b/webui/backend/tests/test_config.py @@ -0,0 +1,56 @@ +"""WebUI config shaping: generation-param merge (thinking-aware).""" +from app.backends.ms_agent import config as cfg + + +def test_deep_merge_refines_nested_dicts_without_replacing_siblings(): + base = {"extra_body": {"enable_thinking": True, "foo": 1}, "temperature": 0.3} + override = {"extra_body": {"foo": 2, "bar": 3}, "top_p": 0.9} + out = cfg._deep_merge(base, override) + assert out == { + "extra_body": {"enable_thinking": True, "foo": 2, "bar": 3}, + "temperature": 0.3, + "top_p": 0.9, + } + assert base["extra_body"] == {"enable_thinking": True, "foo": 1} # inputs untouched + + +def test_webui_generation_params_deep_merges_provider_then_model(monkeypatch): + """A model's advanced_params.extra_body must refine, not replace, the + provider's default_generation_params.extra_body — otherwise setting one + model-level extra_body key drops the provider's enable_thinking.""" + def _fake_get(kind, key): + if kind == "providers": + return {"default_generation_params": { + "extra_body": {"enable_thinking": True, "foo": 1}, "temperature": 0.7}} + if kind == "models": + return {"advanced_params": {"extra_body": {"foo": 2, "bar": 3}}} + return None + + monkeypatch.setattr("app.backends.ms_agent.sidecar.get", _fake_get) + params = cfg._webui_generation_params("deepseek", "deepseek-v4-pro") + assert params == { + "extra_body": {"enable_thinking": True, "foo": 2, "bar": 3}, + "temperature": 0.7, + } + + +def test_thinking_default_by_protocol_provider_model(): + # On by default for anthropic protocol, qwen models, and dashscope/modelscope. + assert cfg.thinking_default("anthropic", "deepseek", "deepseek-v4-pro") is True + assert cfg.thinking_default("openai", "dashscope", "some-model") is True + assert cfg.thinking_default("openai", "modelscope", "x") is True + assert cfg.thinking_default("openai", "openai", "qwen-plus") is True + # Off for other OpenAI-compatible providers. + assert cfg.thinking_default("openai", "deepseek", "deepseek-v4-pro") is False + assert cfg.thinking_default("openai", "kimi", "kimi-k2") is False + + +def test_generation_defaults_surfaces_thinking_flag(): + from app.backends.ms_agent.mapping import _generation_defaults + + assert _generation_defaults("anthropic", "deepseek") == { + "extra_body": {"enable_thinking": True}} + assert _generation_defaults("openai", "deepseek") == { + "extra_body": {"enable_thinking": False}} + assert _generation_defaults("openai", "dashscope") == { + "extra_body": {"enable_thinking": True}} diff --git a/webui/backend/tests/test_error_seal.py b/webui/backend/tests/test_error_seal.py new file mode 100644 index 000000000..3d0b877fb --- /dev/null +++ b/webui/backend/tests/test_error_seal.py @@ -0,0 +1,141 @@ +"""_seal_errored_turn: a failed turn must survive a reload — exactly once. + +The DRIVER_ERROR branch calls this before the done frame. Its contract: + +- a PRE-AGENT failure (credential resolution, agent construction) persisted + nothing — the user's prompt must be written, else refresh blanks the view; +- once an agent exists, the SDK owns turn persistence (it consumed the prompt + and its exception handler seals the round) — the seal here must never + re-append the prompt, or every in-round failure doubles the whole turn; +- the sealing row is flagged ``errored``, never ``interrupted`` (UIs render + the latter as a user-initiated Stop badge); +- the error record is written at most once per turn. +""" +from types import SimpleNamespace + +import app.backends.ms_agent.chat as chat +from app.backends.ms_agent.chat import _INTERRUPTED_PLACEHOLDER, _seal_errored_turn + + +class _StubLog: + """Just enough SessionLog: append/get_all_messages/get_errors/record_error.""" + + def __init__(self, rows=None, errors=None): + self.rows = list(rows or []) + self.errors = list(errors or []) + + def _next_seq(self): + seqs = [r.get("seq", -1) for r in self.rows + self.errors] + return (max(seqs) + 1) if seqs else 0 + + def get_all_messages(self): + return list(self.rows) + + def append(self, record): + record = dict(record) + record.setdefault("seq", self._next_seq()) + self.rows.append(record) + return record["seq"] + + def get_errors(self): + return list(self.errors) + + def record_error(self, event): + event = dict(event) + event.setdefault("seq", self._next_seq()) + self.errors.append(event) + + +def _rt_with_agent(log): + return SimpleNamespace(agent=SimpleNamespace(session_log=log)) + + +def _rt_without_agent(monkeypatch, log): + """Construction failed before the runtime exposed an agent; the seal must + reach the log the way replay does (find_session -> get_session_log).""" + sm = SimpleNamespace(get_session_log=lambda _session: log) + monkeypatch.setattr(chat, "find_session", + lambda _sid: (object(), object(), sm)) + return SimpleNamespace(agent=None) + + +def test_pre_agent_failure_writes_prompt_seal_and_error(monkeypatch): + log = _StubLog() # only the metadata header existed on disk + rt = _rt_without_agent(monkeypatch, log) + + _seal_errored_turn(rt, "sid", "你好", message="ValueError: no key") + + assert [r["role"] for r in log.rows] == ["user", "assistant"] + assert log.rows[0]["content"] == "你好" + seal = log.rows[1] + assert seal["errored"] is True + assert seal["content_placeholder"] is True + assert seal["content"] == _INTERRUPTED_PLACEHOLDER + assert "interrupted" not in seal # errored must not render as a Stop badge + assert [e["message"] for e in log.errors] == ["ValueError: no key"] + assert log.errors[0]["recoverable"] is False + + +def test_in_round_failure_already_sealed_by_sdk_adds_nothing(): + """The regression that doubled every failed turn: the SDK's own seal closes + the tail, and a closed tail must NOT be read as "turn never persisted".""" + log = _StubLog( + rows=[ + {"role": "user", "content": "测试", "seq": 1}, + {"role": "assistant", "content": _INTERRUPTED_PLACEHOLDER, + "content_placeholder": True, "errored": True, "seq": 2}, + ], + errors=[{"message": "AuthenticationError: 401", "round": 0, "seq": 3}], + ) + + _seal_errored_turn( + _rt_with_agent(log), "sid", "测试", + message="AuthenticationError: 401") + + assert [r["role"] for r in log.rows] == ["user", "assistant"] # unchanged + assert len(log.errors) == 1 # SDK's record stands; no duplicate + + +def test_in_round_failure_with_open_tail_seals_without_duplicating_prompt(): + # The SDK persisted this turn's user row and a dangling tool tail, but its + # own seal could not run (e.g. the failure predated pre_step_len capture). + log = _StubLog(rows=[ + {"role": "user", "content": "搜新闻", "seq": 0}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "c1"}], + "seq": 1}, + {"role": "tool", "content": "results", "tool_call_id": "c1", "seq": 2}, + ]) + + _seal_errored_turn( + _rt_with_agent(log), "sid", "搜新闻", message="APIError: <400> x") + + users = [r for r in log.rows if r["role"] == "user"] + assert len(users) == 1 # the SDK's copy is the only copy + assert log.rows[-1]["errored"] is True # tail is sealed + assert len(log.errors) == 1 # reason recorded (SDK never got to it) + + +def test_error_already_recorded_for_this_turn_is_not_duplicated(): + log = _StubLog( + rows=[{"role": "user", "content": "hi", "seq": 0}], + errors=[{"message": "APIError: boom", "seq": 1}], + ) + + _seal_errored_turn( + _rt_with_agent(log), "sid", "hi", message="APIError: boom") + + assert len(log.errors) == 1 + assert log.rows[-1]["errored"] is True # the seal itself still happens + + +def test_consecutive_pre_agent_failures_each_get_their_own_record(monkeypatch): + log = _StubLog() + rt = _rt_without_agent(monkeypatch, log) + _seal_errored_turn(rt, "sid", "第一次", message="E: 1") + _seal_errored_turn(rt, "sid", "第二次", message="E: 2") + _seal_errored_turn(rt, "sid", "第三次", message="E: 3") + + assert [r["role"] for r in log.rows] == [ + "user", "assistant", "user", "assistant", "user", "assistant" + ] + assert [e["message"] for e in log.errors] == ["E: 1", "E: 2", "E: 3"] diff --git a/webui/backend/tests/test_mapping.py b/webui/backend/tests/test_mapping.py new file mode 100644 index 000000000..6b2bb85b1 --- /dev/null +++ b/webui/backend/tests/test_mapping.py @@ -0,0 +1,74 @@ +"""Pure converter round-trips (no server, no network).""" +import pytest + +from app.backends.ms_agent import mcps +from app.backends.ms_agent.mapping import ( + _mask, + _protocol, + decode_model_id, + encode_model_id, +) + + +@pytest.mark.parametrize( + "provider_id,name", + [ + ("openai", "gpt-4o"), + ("my-gw", "Qwen/Qwen3-Max"), # slash in name + ("modelscope", "a:b/c model"), # colon + space + ], +) +def test_model_id_roundtrip(provider_id, name): + mid = encode_model_id(provider_id, name) + assert "/" not in mid and ":" not in mid # url-safe, path-safe + assert decode_model_id(mid) == (provider_id, name) + + +@pytest.mark.parametrize( + "scope,name", + [ + ("global", "fetch"), + ("project:_default", "amap"), + ("project:50326e77f949", "server with spaces"), + ], +) +def test_mcp_id_roundtrip(scope, name): + mid = mcps._encode_id(scope, name) + assert mcps._decode_id(mid) == (scope, name) + + +def test_mcp_stdio_endpoint_roundtrip(): + server = mcps._server("stdio", "npx -y @modelcontextprotocol/server-fetch") + assert server == {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"]} + transport, endpoint = mcps._endpoint(server) + assert transport == "stdio" + assert endpoint == "npx -y @modelcontextprotocol/server-fetch" + + +def test_mcp_remote_endpoint_roundtrip(): + server = mcps._server("sse", "https://mcp.amap.com/sse") + assert server == {"url": "https://mcp.amap.com/sse", "transport": "sse"} + transport, endpoint = mcps._endpoint(server) + assert transport == "sse" + assert endpoint == "https://mcp.amap.com/sse" + + +def test_mcp_stdio_env_roundtrip(): + server = mcps._server("stdio", "npx -y srv", env={"HTTP_PROXY": "http://p"}) + assert server["command"] == "npx" and server["args"] == ["-y", "srv"] + assert server["env"] == {"HTTP_PROXY": "http://p"} + assert mcps._endpoint(server) == ("stdio", "npx -y srv") + + +def test_mcp_remote_headers_roundtrip(): + server = mcps._server("sse", "https://x/sse", headers={"Authorization": "Bearer T"}) + assert server == {"url": "https://x/sse", "transport": "sse", + "headers": {"Authorization": "Bearer T"}} + + +def test_protocol_and_mask(): + assert _protocol("openai_compat") == "openai" + assert _protocol("anthropic_messages") == "anthropic" + assert _mask("") == "" + assert _mask("short") == "****" + assert _mask("sk-secret-1234567890") == "sk-s****7890" diff --git a/webui/backend/tests/test_mcp_env.py b/webui/backend/tests/test_mcp_env.py new file mode 100644 index 000000000..1361c09d4 --- /dev/null +++ b/webui/backend/tests/test_mcp_env.py @@ -0,0 +1,40 @@ +"""MCP ${VAR} placeholder support: .env injection into os.environ and the +probe's runtime view (placeholders resolved before any handshake).""" +import os + +from app.backends.ms_agent import mcp_health + + +def test_env_files_published_to_environ(): + """settings.py publishes backend/.env + repo-root .env into os.environ + (key presence only — values are never asserted or printed).""" + # CORS_ORIGINS lives in backend/.env; its being loadable proves the + # injection ran at import time (conftest imports app.core.settings). + from app.core.settings import settings + + assert settings.cors_origins # settings side intact + assert "CORS_ORIGINS" in os.environ or "cors_origins" in os.environ + + +def test_probe_runtime_view_expands_placeholders(monkeypatch): + monkeypatch.setenv("PROBE_TOKEN", "tok-123") + entry = { + "url": "https://gw/sse", + "transport": "sse", + "headers": {"Authorization": "Bearer ${PROBE_TOKEN}"}, + } + view = mcp_health._runtime_view(entry) + assert view["headers"]["Authorization"] == "Bearer tok-123" + # Original entry untouched (management surfaces keep the placeholder). + assert entry["headers"]["Authorization"] == "Bearer ${PROBE_TOKEN}" + # Idempotent on already-expanded entries. + assert mcp_health._runtime_view(view) == view + + +def test_probe_stdio_uses_expanded_command(monkeypatch): + monkeypatch.setenv("MY_BIN", "python3") + entry = {"command": "${MY_BIN}", "args": ["-V"]} + import asyncio + + ok, err = asyncio.run(mcp_health.check_server(entry)) + assert ok, err # python3 resolves on PATH once expanded diff --git a/webui/backend/tests/test_mcp_order_scope.py b/webui/backend/tests/test_mcp_order_scope.py new file mode 100644 index 000000000..3f383fc00 --- /dev/null +++ b/webui/backend/tests/test_mcp_order_scope.py @@ -0,0 +1,168 @@ +"""MCP list ordering and project-scope removal. + +Two behaviours users depend on and that regressed once already: + +* The order shown is the order of ``mcp.json``. Editing a server (toggling it + most of all) must not move its card; only reordering that file may. +* "Remove" on a project-owned server deletes it. The SDK's project-scope + ``remove`` only writes a mask (``{enabled: false, _removed: true}``), which + made removal look like a mere disable — the card stayed, switched off. +""" +import json + +import pytest + +from app.backends.errors import BadRequest +from app.backends.ms_agent import mcps +from app.backends.ms_agent import projects as P +from app.schemas.mcp import McpCreate, McpUpdate +from app.schemas.project import ProjectCreate + + +@pytest.fixture +def two_global_mcps(): + first = mcps.create_mcp( + McpCreate(name="order-first", transport="sse", + endpoint="https://example.com/a/sse", scope="global") + ) + second = mcps.create_mcp( + McpCreate(name="order-second", transport="sse", + endpoint="https://example.com/b/sse", scope="global") + ) + yield first, second + for m in (first, second): + try: + mcps.delete_mcp(m.id) + except Exception: + pass + + +@pytest.fixture +def project(): + proj = P.create_project(ProjectCreate(name="mcp-scope-test")) + yield proj + P.delete_project(proj.id) + + +def _names(scope="global"): + return [m.name for m in mcps.list_mcps(scope)] + + +def test_toggling_keeps_list_order_and_added_at(two_global_mcps): + first, second = two_global_mcps + before = _names() + assert before.index("order-first") < before.index("order-second") + added_at = {m.name: m.created_at for m in mcps.list_mcps("global")} + + # Toggle the FIRST one: the case that used to send it to the end, because the + # update was a remove+add (re-appending the key and restamping added_at) + # while the list was sorted by that timestamp. + mcps.update_mcp(first.id, McpUpdate(enabled=False)) + assert _names() == before + mcps.update_mcp(first.id, McpUpdate(enabled=True)) + mcps.update_mcp(second.id, McpUpdate(enabled=False)) + mcps.update_mcp(second.id, McpUpdate(enabled=True)) + assert _names() == before + + after = {m.name: m.created_at for m in mcps.list_mcps("global")} + assert after == added_at + + +def test_editing_endpoint_keeps_position(two_global_mcps): + first, _ = two_global_mcps + before = _names() + updated = mcps.update_mcp( + first.id, McpUpdate(endpoint="https://example.com/moved/sse") + ) + assert updated.endpoint == "https://example.com/moved/sse" + assert _names() == before + + +def test_removing_project_mcp_deletes_it(project): + scope = f"project:{project.id}" + created = mcps.create_mcp( + McpCreate(name="proj-tool", transport="sse", + endpoint="https://example.com/p/sse", scope=scope) + ) + assert _names(scope) == ["proj-tool"] + + mcps.delete_mcp(created.id) + + # Gone from the listing AND from disk — not left behind as a disabled row. + assert _names(scope) == [] + path = mcps._mm_for(scope)[0].project_mcp_path + on_disk = json.loads(path.read_text(encoding="utf-8")).get("mcpServers", {}) + assert "proj-tool" not in on_disk + + +def test_mask_rows_are_not_listed(project): + """A row that defines no server (the SDK's removal mask) is not a card.""" + scope = f"project:{project.id}" + mm, sdk_scope = mcps._mm_for(scope) + mm.add("masked", {"enabled": False, "_removed": True}, scope=sdk_scope) + + assert "masked" not in _names(scope) + + +def _replace(scope, *specs): + return mcps.replace_mcps( + scope, + [ + McpCreate(name=n, transport="sse", + endpoint=f"https://example.com/{n}/sse", scope=scope) + for n in specs + ], + ) + + +def test_replace_renames_instead_of_duplicating(project): + """The raw-JSON editor's document is the desired state: a renamed key renames + that server rather than adding a second one beside it.""" + scope = f"project:{project.id}" + _replace(scope, "keep-me", "rename-me") + assert _names(scope) == ["keep-me", "rename-me"] + + _replace(scope, "keep-me", "renamed") + + assert _names(scope) == ["keep-me", "renamed"] + + +def test_replace_follows_document_order(project): + scope = f"project:{project.id}" + _replace(scope, "a", "b", "c") + assert _names(scope) == ["a", "b", "c"] + + _replace(scope, "c", "a", "b") + assert _names(scope) == ["c", "a", "b"] + + +def test_replace_rejects_bad_payload_without_touching_anything(project): + """Validation happens before the first write — the old client deleted every + server first, so one rejected entry emptied the whole scope.""" + scope = f"project:{project.id}" + _replace(scope, "survivor-1", "survivor-2") + before = _names(scope) + + with pytest.raises(BadRequest): + mcps.replace_mcps( + scope, + [ + McpCreate(name="survivor-1", transport="sse", + endpoint="https://example.com/1/sse", scope=scope), + McpCreate(name="broken", transport="stdio", + endpoint=" ", scope=scope), + ], + ) + + assert _names(scope) == before + + +def test_replace_keeps_added_at_of_surviving_servers(project): + scope = f"project:{project.id}" + _replace(scope, "old-timer") + added_at = {m.name: m.created_at for m in mcps.list_mcps(scope)} + + _replace(scope, "old-timer", "newcomer") + + after = {m.name: m.created_at for m in mcps.list_mcps(scope)} + assert after["old-timer"] == added_at["old-timer"] diff --git a/webui/backend/tests/test_memory_backend_lock.py b/webui/backend/tests/test_memory_backend_lock.py new file mode 100644 index 000000000..cfec1b729 --- /dev/null +++ b/webui/backend/tests/test_memory_backend_lock.py @@ -0,0 +1,94 @@ +"""Memory-backend lock: the choice freezes once memory has been enabled. + +The backend decides the on-disk storage layout, so switching it after storage +went live would orphan whatever is already there. Toggling ``memory_enabled`` +itself stays free — only the backend is frozen, and the lock must survive the +user turning memory back off. +""" +import pytest + +from app.backends.errors import BadRequest +from app.backends.ms_agent import projects as P +from app.schemas.project import ProjectCreate, ProjectUpdate + + +@pytest.fixture +def unlocked_project(): + """A project created with memory OFF — backend still switchable.""" + proj = P.create_project( + ProjectCreate(name="lock-test", memory_enabled=False, + memory_backend="file")) + yield proj + P.delete_project(proj.id) + + +def test_backend_unlocked_while_memory_never_enabled(unlocked_project): + assert unlocked_project.memory_enabled is False + assert unlocked_project.memory_backend_locked is False + + got = P.update_project(unlocked_project.id, + ProjectUpdate(memory_backend="vector")) + assert got.memory_backend == "vector" + assert got.memory_backend_locked is False + + +def test_enabling_memory_locks_the_backend(unlocked_project): + got = P.update_project(unlocked_project.id, + ProjectUpdate(memory_enabled=True)) + assert got.memory_backend_locked is True + + +def test_lock_survives_disabling_memory_again(unlocked_project): + """The rule that motivates a sticky flag rather than reading + ``memory_enabled``: storage exists from the first enable onwards.""" + P.update_project(unlocked_project.id, ProjectUpdate(memory_enabled=True)) + got = P.update_project(unlocked_project.id, + ProjectUpdate(memory_enabled=False)) + assert got.memory_enabled is False + assert got.memory_backend_locked is True + + +def test_changing_locked_backend_is_rejected(unlocked_project): + P.update_project(unlocked_project.id, ProjectUpdate(memory_enabled=True)) + with pytest.raises(BadRequest): + P.update_project(unlocked_project.id, + ProjectUpdate(memory_backend="vector")) + + +def test_resending_same_locked_backend_is_tolerated(unlocked_project): + """The edit form submits the whole shape; an unchanged value must not 400.""" + P.update_project(unlocked_project.id, ProjectUpdate(memory_enabled=True)) + got = P.update_project(unlocked_project.id, + ProjectUpdate(memory_backend="file", + memory_enabled=True)) + assert got.memory_backend == "file" + + +def test_created_with_memory_on_is_locked_immediately(): + proj = P.create_project( + ProjectCreate(name="lock-test-on", memory_enabled=True, + memory_backend="vector")) + try: + assert proj.memory_backend == "vector" + assert proj.memory_backend_locked is True + with pytest.raises(BadRequest): + P.update_project(proj.id, ProjectUpdate(memory_backend="file")) + finally: + P.delete_project(proj.id) + + +def test_project_path_cannot_be_changed_after_creation(unlocked_project): + """The directory IS the project's identity and holds all of its data; the + SDK's update() only rewrites the field, nothing moves on disk.""" + with pytest.raises(BadRequest): + P.update_project(unlocked_project.id, + ProjectUpdate(local_path="/tmp/somewhere-else")) + + +def test_resending_same_project_path_is_tolerated(unlocked_project): + """The edit form submits the whole shape; an unchanged path must not 400.""" + got = P.update_project( + unlocked_project.id, + ProjectUpdate(local_path=unlocked_project.local_path, name="renamed")) + assert got.name == "renamed" + assert got.local_path == unlocked_project.local_path diff --git a/webui/backend/tests/test_memory_doc.py b/webui/backend/tests/test_memory_doc.py new file mode 100644 index 000000000..82cbbd0f5 --- /dev/null +++ b/webui/backend/tests/test_memory_doc.py @@ -0,0 +1,88 @@ +"""File-backend memory as ONE markdown document. + +With ``memory_backend="file"`` memory is a single MEMORY.md that the agent +reads, so the UI previews/edits it as a document rather than as separate items. +The document and item APIs are two views of the SAME store. The vector backend +has no such file and must reject the document API. +""" +import asyncio + +import pytest + +from app.backends.errors import BadRequest +from app.backends.ms_agent import memory as M +from app.backends.ms_agent import projects as P +from app.schemas.memory import MemoryDocUpdate, MemoryItemCreate +from app.schemas.project import ProjectCreate + + +@pytest.fixture +def file_project(): + proj = P.create_project( + ProjectCreate(name="doc-test", memory_enabled=True, + memory_backend="file")) + yield proj + P.delete_project(proj.id) + + +@pytest.fixture +def vector_project(): + proj = P.create_project( + ProjectCreate(name="doc-test-vec", memory_enabled=True, + memory_backend="vector")) + yield proj + P.delete_project(proj.id) + + +def test_empty_document_on_a_fresh_project(file_project): + assert M.get_doc(file_project.id).content == "" + + +def test_document_roundtrip(file_project): + md = "# Project memory\n\n- prefers Chinese\n- keep it short\n" + saved = M.put_doc(file_project.id, MemoryDocUpdate(content=md)) + assert "prefers Chinese" in saved.content + assert M.get_doc(file_project.id).content == saved.content + + +def test_document_and_items_share_one_store(file_project): + """An item added the old way shows up in the document, and document lines + show up as items — they are the same MEMORY.md.""" + M.create_item(file_project.id, MemoryItemCreate(content="likes brevity")) + assert "likes brevity" in M.get_doc(file_project.id).content + + M.put_doc(file_project.id, MemoryDocUpdate(content="- written as a doc\n")) + contents = [i.content for i in asyncio.run(M.list_items(file_project.id))] + assert "- written as a doc" in contents + + +def test_clearing_the_document_is_allowed(file_project): + M.put_doc(file_project.id, MemoryDocUpdate(content="- something\n")) + assert M.put_doc(file_project.id, MemoryDocUpdate(content="")).content == "" + + +def test_document_api_rejects_vector_backend(vector_project): + with pytest.raises(BadRequest): + M.get_doc(vector_project.id) + with pytest.raises(BadRequest): + M.put_doc(vector_project.id, MemoryDocUpdate(content="x")) + + +def test_vector_memories_are_read_only_apart_from_deletion(vector_project): + """Vector entries come from the agent's fact extraction during conversation, + so the UI only offers removal — creating/editing by hand is refused.""" + from app.schemas.memory import MemoryItemCreate, MemoryItemUpdate + + with pytest.raises(BadRequest): + M.create_item(vector_project.id, MemoryItemCreate(content="manual note")) + with pytest.raises(BadRequest): + M.update_item(vector_project.id, "mem_whatever", + MemoryItemUpdate(content="edited")) + + +def test_file_memories_stay_writable(file_project): + """The file backend is the editable one — the guard must not leak to it.""" + from app.schemas.memory import MemoryItemCreate + + item = M.create_item(file_project.id, MemoryItemCreate(content="a note")) + assert item.content == "a note" diff --git a/webui/backend/tests/test_memory_vector.py b/webui/backend/tests/test_memory_vector.py new file mode 100644 index 000000000..9710cf8b9 --- /dev/null +++ b/webui/backend/tests/test_memory_vector.py @@ -0,0 +1,547 @@ +"""Vector-backend memory: config resolution and the mem0-backed item APIs. + +These were the completely untested paths, and the two config bugs they now +cover both presented identically in the product -- a conversation that looks +fine while memory stays empty forever, because ``Mem0Backend.on_messages`` +swallows its failures: + +* the fact-extraction LLM was declared as mem0's ``openai`` provider whatever + protocol the active model actually spoke, so an Anthropic-protocol endpoint + (DeepSeek's ``/anthropic``) got ``/chat/completions`` posted at it and 404'd; +* mem0 opens a second, process-global qdrant store for telemetry, and embedded + qdrant locks per path -- so the machine could only ever hold ONE live vector + project. + +Everything here is offline: the mem0 client is faked, and the config helpers +are pure functions over a settings dict. +""" +from pathlib import Path + +import asyncio + +import pytest + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import config as C +from app.backends.ms_agent import memory as M +from app.backends.ms_agent import projects as P +from app.schemas.project import ProjectCreate + + +# ── fact-extraction LLM resolution (pure) ───────────────────────────────── + +def _settings(provider: str, protocol: str, base_url: str) -> dict: + return { + "llm": { + "provider": provider, + "model": f"{provider}-chat", + "api_key": "k-llm", + "base_url": base_url, + }, + "providers": { + provider: { + "protocol": protocol, + "api_key": "k-prov", + "base_url": base_url, + } + }, + } + + +def test_openai_protocol_provider_is_used_as_is(): + block = C._mem0_llm( + _settings("dashscope", "openai", "https://example.test/compatible/v1")) + assert block == { + "provider": "openai", + "config": { + "model": "dashscope-chat", + "api_key": "k-llm", + "openai_base_url": "https://example.test/compatible/v1", + }, + } + + +def test_anthropic_protocol_falls_back_to_mem0_native_provider(): + """The regression that made vector memory a no-op. + + DeepSeek configured on its Anthropic endpoint must NOT be handed to mem0 as + `openai` pointing at `/anthropic` -- mem0's DeepSeek adapter is an OpenAI + client underneath, so it needs the vendor's OpenAI-compatible url from the + SDK provider registry, not the one sitting in settings.json. + """ + block = C._mem0_llm( + _settings("deepseek", "anthropic", "https://api.deepseek.com/anthropic")) + assert block["provider"] == "deepseek" + assert block["config"]["model"] == "deepseek-chat" + base = block["config"]["deepseek_base_url"] + assert base == "https://api.deepseek.com/v1" + assert "anthropic" not in base + + +def test_non_openai_vendor_without_a_native_adapter_is_declined(): + """Better no `llm` key (mem0 warns and uses its default) than a provider + we know cannot be reached -- and never a silent mislabel.""" + assert C._mem0_llm(_settings("acme", "anthropic", "https://acme.test")) is None + + +@pytest.mark.parametrize("settings", [ + {}, + {"llm": {"provider": "deepseek"}}, # no model + {"llm": {"provider": "deepseek", "model": "m"}}, # no credentials +]) +def test_incomplete_settings_yield_no_llm_block(settings): + assert C._mem0_llm(settings) is None + + +# ── embedder resolution (explicit → conversation provider → local) ───────── + +def _settings_with(llm_provider="zhipu", **providers): + return { + "llm": {"provider": llm_provider, "model": "chat-model", + "api_key": "k", "base_url": providers.get(llm_provider, {}).get("base_url")}, + "providers": providers, + } + + +def test_default_embedder_follows_the_conversation_provider(): + """No explicit choice → the provider the user already picked for chat, + with its known embedding model. Never a silently different vendor.""" + settings = _settings_with( + "zhipu", zhipu={"api_key": "z", "base_url": "https://zhipu.example/v4"}) + desc = C._resolve_embedder(settings, {}) + assert (desc["mode"], desc["provider"]) == ("provider", "zhipu") + assert desc["model"] == C._KNOWN_EMBED_MODELS["zhipu"] + assert desc["fallback_reason"] is None + + +def test_conversation_provider_without_embeddings_falls_back_to_local(monkeypatch): + """The chat provider may serve no /embeddings at all (deepseek, kimi, a + custom gateway). The default then goes LOCAL — visible in + fallback_reason — instead of silently billing some other vendor.""" + monkeypatch.setattr(C, "_local_embed_available", lambda: True) + settings = _settings_with( + "deepseek", deepseek={"api_key": "d", "base_url": "https://api.deepseek.com/anthropic"}) + desc = C._resolve_embedder(settings, {}) + assert desc["mode"] == "local" + assert desc["model"] == C._LOCAL_EMBED_MODEL + assert "deepseek" in (desc["fallback_reason"] or "") + + +def test_explicit_provider_is_never_silently_switched(monkeypatch): + """A pinned provider that cannot embed is an ERROR, not a fallback — the + user chose it; switching behind their back is the old bug.""" + monkeypatch.setattr(C, "_local_embed_available", lambda: True) + settings = _settings_with( + "zhipu", zhipu={"api_key": "z", "base_url": "https://zhipu.example/v4"}) + with pytest.raises(C.MemoryConfigError) as exc: + C._resolve_embedder(settings, {"embed_provider_id": "kimi"}) + assert exc.value.code == "embed_unavailable" + + +def test_explicit_local_mode_requires_the_extra(monkeypatch): + monkeypatch.setattr(C, "_local_embed_available", lambda: False) + with pytest.raises(C.MemoryConfigError) as exc: + C._resolve_embedder(_settings_with(), {"embed_mode": "local"}) + assert exc.value.code == "local_missing" + assert "local-embed" in str(exc.value) # the message names the remedy + + +def test_no_provider_and_no_local_is_a_clear_error(monkeypatch): + monkeypatch.setattr(C, "_local_embed_available", lambda: False) + with pytest.raises(C.MemoryConfigError) as exc: + C._resolve_embedder({}, {}) + assert exc.value.code == "local_missing" + + +def test_explicit_embed_model_overrides_the_known_default(): + settings = _settings_with( + "zhipu", zhipu={"api_key": "z", "base_url": "https://zhipu.example/v4"}) + desc = C._resolve_embedder(settings, {"embed_provider_id": "zhipu", + "embed_model": "embedding-2"}) + assert desc["model"] == "embedding-2" + + +# ── embedder identity (recorded once, then enforced) ─────────────────────── + +def _proj(tmp_path): + return type("P", (), {"id": "p", "path": str(tmp_path)})() + + +def test_identity_is_recorded_on_first_build(monkeypatch, tmp_path): + monkeypatch.setattr(C, "_probe_embed_dimension", lambda desc: (1024, True)) + proj = _proj(tmp_path) + identity = C._resolve_embedder_identity( + proj, {"mode": "provider", "provider": "zhipu", "model": "embedding-3", + "api_key": "k", "base_url": "https://x/v4"}) + assert identity["dimension"] == 1024 and identity["pass_dimensions"] + stored = C._load_embedder_identity(proj) + assert (stored["provider"], stored["model"]) == ("zhipu", "embedding-3") + + +def test_identity_mismatch_refuses_instead_of_mixing_spaces(monkeypatch, tmp_path): + """A store built with model A must not be written with model B: mixed + vector spaces don't error, they just make recall garbage. The error names + both models and points at the rebuild.""" + monkeypatch.setattr(C, "_probe_embed_dimension", lambda desc: (1024, True)) + proj = _proj(tmp_path) + C._resolve_embedder_identity( + proj, {"mode": "provider", "provider": "zhipu", "model": "embedding-3"}) + with pytest.raises(C.MemoryConfigError) as exc: + C._resolve_embedder_identity( + proj, {"mode": "local", "provider": None, + "model": C._LOCAL_EMBED_MODEL}) + assert exc.value.code == "embedder_mismatch" + assert "zhipu/embedding-3" in str(exc.value) + + +def test_same_identity_skips_the_probe(monkeypatch, tmp_path): + """The dimension probe is a network call; a recorded identity must satisfy + later builds without re-probing.""" + proj = _proj(tmp_path) + monkeypatch.setattr(C, "_probe_embed_dimension", lambda desc: (1024, False)) + desc = {"mode": "provider", "provider": "zhipu", "model": "embedding-3"} + C._resolve_embedder_identity(proj, desc) + + def _explode(_desc): + raise AssertionError("re-probed a recorded identity") + + monkeypatch.setattr(C, "_probe_embed_dimension", _explode) + identity = C._resolve_embedder_identity(proj, desc) + assert identity["dimension"] == 1024 + + +def test_mem0_telemetry_is_off(): + """Not cosmetic: mem0 2.x opens a process-global ~/.mem0/migrations_qdrant + per Memory instance, and embedded qdrant locks per path -- leaving telemetry + on caps the whole machine at one live vector project.""" + import os + + assert os.environ["MEM0_TELEMETRY"] == "false" + + +# ── list / delete over a faked mem0 ─────────────────────────────────────── + +class _FakeMem0: + """Enough of mem0.Memory for the adapter, in the 2.x shape (results + envelope, `filters=` + `top_k`). The 1.x path is exercised separately.""" + + def __init__(self, rows=None, legacy=False): + self.rows = list(rows or []) + self.legacy = legacy + self.deleted: list[str] = [] + + def get_all(self, **kwargs): + if self.legacy: + # 1.x rejects the 2.x kwargs, which is what drives the fallback. + if "filters" in kwargs or "top_k" in kwargs: + raise TypeError("unexpected keyword argument") + return list(self.rows) # bare list, no envelope + assert kwargs["filters"] == {"user_id": self.pid} + assert kwargs["top_k"] > 20, "default top_k truncates a notes list" + return {"results": list(self.rows)} + + def delete(self, memory_id): + if memory_id not in [r["id"] for r in self.rows]: + raise ValueError("Memory not found") + self.rows = [r for r in self.rows if r["id"] != memory_id] + self.deleted.append(memory_id) + + +@pytest.fixture +def vector_project(): + proj = P.create_project( + ProjectCreate(name="vec-test", memory_enabled=True, + memory_backend="vector")) + yield proj + P.delete_project(proj.id) + + +@pytest.fixture +def fake_mem0(monkeypatch, vector_project): + """Swap the real client in, keeping _mem0_for's contextmanager shape.""" + import contextlib + + fake = _FakeMem0() + fake.pid = vector_project.id + + @contextlib.contextmanager + def _for(_proj): + yield fake + + monkeypatch.setattr(M, "_mem0_for", _for) + monkeypatch.setattr(M, "_invalidate_live", lambda _proj: None) + return fake + + +def test_list_maps_mem0_rows_to_items(fake_mem0, vector_project): + fake_mem0.rows = [ + {"id": "uuid-1", "memory": "prefers concise Chinese", + "updated_at": "2026-08-06T04:53:21+00:00"}, + {"id": "uuid-2", "memory": "develops on macOS", + "created_at": "2026-08-06T04:53:22+00:00"}, + ] + items = asyncio.run(M.list_items(vector_project.id)) + assert [i.id for i in items] == ["uuid-1", "uuid-2"] + assert items[0].content == "prefers concise Chinese" + # created_at stands in when the row has no updated_at. + assert items[1].updated_at is not None + + +def test_list_drops_contentless_rows(fake_mem0, vector_project): + fake_mem0.rows = [{"id": "a", "memory": ""}, {"id": "b", "memory": "kept"}] + assert [i.content for i in asyncio.run(M.list_items(vector_project.id))] == ["kept"] + + +def test_list_falls_back_to_the_1x_get_all_signature(fake_mem0, vector_project): + fake_mem0.legacy = True + fake_mem0.rows = [{"id": "old", "memory": "from mem0 1.x"}] + assert [i.id for i in asyncio.run(M.list_items(vector_project.id))] == ["old"] + + +def test_delete_removes_by_id(fake_mem0, vector_project): + fake_mem0.rows = [{"id": "uuid-1", "memory": "wrong fact"}] + asyncio.run(M.delete_item(vector_project.id, "uuid-1")) + assert fake_mem0.deleted == ["uuid-1"] + assert asyncio.run(M.list_items(vector_project.id)) == [] + + +def test_delete_of_an_unknown_id_is_a_404(fake_mem0, vector_project): + with pytest.raises(NotFound): + asyncio.run(M.delete_item(vector_project.id, "nope")) + + +def test_writes_are_refused_on_the_vector_backend(fake_mem0, vector_project): + """Vector entries come from the agent's fact extraction; the UI offers no + hand-authoring, so the API must not either (removal stays allowed).""" + from app.schemas.memory import MemoryItemCreate, MemoryItemUpdate + + with pytest.raises(BadRequest): + M.create_item(vector_project.id, MemoryItemCreate(content="typed by hand")) + with pytest.raises(BadRequest): + M.update_item(vector_project.id, "uuid-1", MemoryItemUpdate(content="edited")) + + +# ── live-instance reuse (the thing standing between us and a lock error) ── + +def test_mem0_for_borrows_a_live_instance_instead_of_opening_a_second( + monkeypatch, vector_project): + """Embedded qdrant is single-client. When a chat runtime already holds the + store, the API MUST reuse its handle -- building a transient one would + raise "already accessed by another instance" instead of listing memories. + """ + from app.backends.ms_agent.common import pm + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + # _mem0_for takes the SDK project (it reads `.path`), not the API schema. + proj = pm().get(vector_project.id) + + live = object() + holder = type("Orchestrator", (), {})() + holder.mem_config = type("Cfg", (), {})() + holder.mem_config.base_dir = str(memory_dir(proj.path)) + holder._backend = type("Backend", (), {})() + holder._backend._mem0 = live + + def _explode(*_a, **_kw): + raise AssertionError("built a second client while one was live") + + monkeypatch.setattr(C, "_mem0_options", _explode) + monkeypatch.setitem(SharedMemoryManager._instances, "test-live", holder) + try: + with M._mem0_for(proj) as m0: + assert m0 is live + finally: + SharedMemoryManager._instances.pop("test-live", None) + + +# ── config injection ────────────────────────────────────────────────────── + +def _memory_node(project, settings): + from omegaconf import OmegaConf + + cfg = C._apply_webui_memory(OmegaConf.create({}), project) + node = OmegaConf.select(cfg, "memory.unified_memory") + return None if node is None else OmegaConf.to_container(node) + + +def test_vector_project_gets_the_mem0_storage_backend(monkeypatch, vector_project): + monkeypatch.setattr(C, "_mem0_options", lambda _p: {"embedder": {}, "vector_store": {}}) + node = _memory_node(vector_project, None) + assert node["storage"]["backend"] == "mem0" + assert node["namespace"]["user_id"] == vector_project.id + # add_after_step is what activates per-step ingestion; without it the agent + # never calls on_messages and nothing is ever written. + assert node["add_after_step"]["user_id"] == vector_project.id + + +def test_unbuildable_vector_memory_disables_memory_not_file_fallback( + monkeypatch, vector_project): + """A vector project whose memory cannot be built runs WITHOUT memory — + never as a silent file fallback, which would write a MEMORY.md the vector + UI never shows. The reason reaches the user via GET /memory/status.""" + + def _raise(_p): + raise C.MemoryConfigError("embed_unavailable", "no embedder") + + monkeypatch.setattr(C, "_mem0_options", _raise) + assert _memory_node(vector_project, None) is None + + +# ── status & rebuild ─────────────────────────────────────────────────────── + +def test_status_for_a_file_project_is_minimal(): + proj = P.create_project( + ProjectCreate(name="status-file", memory_enabled=True, + memory_backend="file")) + try: + status = M.get_status(proj.id) + assert status.backend == "file" + assert status.embedder is None and status.error is None + finally: + P.delete_project(proj.id) + + +def test_status_surfaces_the_resolution_error(monkeypatch, vector_project): + """The whole point of /status: a config problem stops being an empty + panel and becomes a machine-readable reason.""" + + def _raise(_settings, _mem_cfg): + raise C.MemoryConfigError("local_missing", "run uv sync --extra local-embed") + + monkeypatch.setattr(C, "_resolve_embedder", _raise) + status = M.get_status(vector_project.id) + assert status.backend == "vector" + assert status.error.code == "local_missing" + assert "local-embed" in status.error.message + + +def test_status_reports_identity_mismatch_with_rebuild_code( + monkeypatch, vector_project): + from app.backends.ms_agent.common import pm + + sdk_proj = pm().get(vector_project.id) + monkeypatch.setattr(C, "_probe_embed_dimension", lambda desc: (384, False)) + C._resolve_embedder_identity( + sdk_proj, {"mode": "local", "provider": None, "model": "old-model"}) + monkeypatch.setattr( + C, "_resolve_embedder", + lambda s, m: {"mode": "local", "provider": None, "model": "new-model", + "fallback_reason": None}) + status = M.get_status(vector_project.id) + assert status.error is not None and status.error.code == "embedder_mismatch" + # The stored identity (what the store was built with) is what's shown. + assert status.embedder.model == "old-model" + + +def test_rebuild_backs_up_the_store_and_clears_identity( + monkeypatch, vector_project): + import asyncio + + from app.backends.ms_agent.common import pm + from ms_agent.project.paths import memory_dir + + sdk_proj = pm().get(vector_project.id) + mem_dir = Path(str(memory_dir(sdk_proj.path))) + (mem_dir / "qdrant").mkdir(parents=True) + (mem_dir / "qdrant" / "meta.json").write_text("{}") + (mem_dir / "ingest_state.json").write_text('{"hashes": ["x"]}') + monkeypatch.setattr(C, "_probe_embed_dimension", lambda desc: (384, False)) + C._resolve_embedder_identity( + sdk_proj, {"mode": "local", "provider": None, "model": "old-model"}) + monkeypatch.setattr( + C, "_resolve_embedder", + lambda s, m: {"mode": "local", "provider": None, "model": "new-model", + "fallback_reason": None}) + + asyncio.run(M.rebuild(vector_project.id)) + + assert not (mem_dir / "qdrant").exists() + backups = list(mem_dir.glob("qdrant.bak-*")) + assert len(backups) == 1 # moved aside, never deleted + assert (backups[0] / "meta.json").exists() + assert C._load_embedder_identity(sdk_proj) is None + assert not (mem_dir / "ingest_state.json").exists() + + +# ── per-project memory-model ownership ───────────────────────────────────── + +def test_creation_materializes_global_defaults(monkeypatch): + """Global settings are a factory template: copied into the project at + creation, then never read again for it — changing a global default later + must not touch existing projects.""" + from app.backends.ms_agent import sidecar + + sidecar.put("agent_settings", "memory_models", { + "llm_provider_id": "zhipu", "llm_model": "glm-5", + "embed_mode": "local", "embed_provider_id": None, + "embed_model": None, "recall_top_k": 7, + }) + proj = P.create_project(ProjectCreate(name="materialize-me")) + try: + assert proj.memory_llm_provider_id == "zhipu" + assert proj.memory_embed_mode == "local" + assert proj.memory_recall_top_k == 7 + # Now change the global default — the project must keep its copy. + sidecar.put("agent_settings", "memory_models", { + "llm_provider_id": None, "llm_model": None, + "embed_mode": "provider", "embed_provider_id": None, + "embed_model": None, "recall_top_k": None, + }) + again = P.get_project(proj.id) + assert again.memory_llm_provider_id == "zhipu" + assert again.memory_embed_mode == "local" + finally: + P.delete_project(proj.id) + sidecar.put("agent_settings", "memory_models", {}) + + +def test_explicit_create_values_beat_global_defaults(monkeypatch): + from app.backends.ms_agent import sidecar + + sidecar.put("agent_settings", "memory_models", {"embed_mode": "local"}) + proj = P.create_project(ProjectCreate( + name="explicit-wins", memory_embed_mode="provider", + memory_embed_provider_id="zhipu")) + try: + assert proj.memory_embed_mode == "provider" + assert proj.memory_embed_provider_id == "zhipu" + finally: + P.delete_project(proj.id) + sidecar.put("agent_settings", "memory_models", {}) + + +def test_update_replaces_the_group_and_feeds_resolution(): + """The edit modal owns the whole group; config resolution must read the + PROJECT's values (not the globals).""" + from app.schemas.project import ProjectUpdate + + proj = P.create_project(ProjectCreate(name="group-replace")) + try: + P.update_project(proj.id, ProjectUpdate( + memory_llm_provider_id="zhipu", memory_llm_model="glm-5", + memory_embed_mode="local", memory_embed_provider_id=None, + memory_embed_model=None, memory_recall_top_k=5)) + from app.backends.ms_agent.common import pm + + mem_cfg = C._project_memory_models(pm().get(proj.id)) + assert mem_cfg["llm_model"] == "glm-5" + assert mem_cfg["embed_mode"] == "local" + assert mem_cfg["recall_top_k"] == 5 + finally: + P.delete_project(proj.id) + + +def test_legacy_project_without_group_resolves_as_follow(): + proj = P.create_project(ProjectCreate(name="legacy-like")) + try: + from app.backends.ms_agent import sidecar + from app.backends.ms_agent.common import pm + + # Simulate a pre-feature project: drop the materialized group. + meta = sidecar.get("projects", proj.id, {}) or {} + meta.pop("memory_models", None) + sidecar.put("projects", proj.id, meta) + assert C._project_memory_models(pm().get(proj.id)) == {} + finally: + P.delete_project(proj.id) diff --git a/webui/backend/tests/test_model_link.py b/webui/backend/tests/test_model_link.py new file mode 100644 index 000000000..42219ecc5 --- /dev/null +++ b/webui/backend/tests/test_model_link.py @@ -0,0 +1,461 @@ +"""Offline unit tests for the model link, MCP health probe, and session naming.""" +import json +from pathlib import Path + +import pytest + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent import ( + agent_settings, + common, + config, + instructions, + mcp_health, + mcps, + model_link, + sessions, + skills, +) +from app.backends.ms_agent.mapping import encode_model_id +from app.schemas.agent_settings import AgentSettings +from app.schemas.instruction import InstructionUpsert +from app.schemas.mcp import McpCreate, McpUpdate +from app.schemas.skill import SkillCreate, SkillUpdate + + +def test_active_model_parsing(): + assert model_link.active_model({"default_model": "openai/qwen-max"}) == ("openai", "qwen-max") + # bare name -> infer provider from the catalog + assert model_link.active_model( + {"default_model": "m1", "providers": {"p": {"models": ["m1"]}}} + ) == ("p", "m1") + # bare name -> fall back to the llm block's provider + assert model_link.active_model( + {"default_model": "m1", "llm": {"provider": "openai"}} + ) == ("openai", "m1") + assert model_link.active_model({"llm": {"provider": "o", "model": "m"}}) == ("o", "m") + assert model_link.active_model({}) == (None, None) + + +def test_set_active_model_registers_and_preserves_creds(): + # conftest points MS_AGENT_HOME at a temp dir, so this writes there. + model_link._save({"llm": {"provider": "openai", "model": "old", + "api_key": "k", "base_url": "https://dash/compatible/v1"}}) + model_link.set_active_model("openai", "new") + d = model_link._load() + assert d["default_model"] == "openai/new" + assert d["llm"]["model"] == "new" + assert d["llm"]["base_url"] == "https://dash/compatible/v1" # working creds preserved + assert "new" in d["providers"]["openai"]["models"] # registered in the catalog + + +def test_set_active_model_honors_explicit_key_revoke(): + model_link._save({ + "llm": {"provider": "openai", "model": "old", "api_key": "old-key"}, + "providers": {"openai": {"protocol": "openai", "api_key": "", "models": ["old"]}}, + }) + model_link.set_active_model("openai", "old") + d = model_link._load() + assert "api_key" not in d["llm"] + + +def test_agent_settings_update_preserves_global_instruction(): + model_link._save({ + "default_model": "openai/old", + "llm": {"provider": "openai", "model": "old", "api_key": "k"}, + "providers": {"openai": {"protocol": "openai", "api_key": "k", "models": ["old", "new"]}}, + }) + instructions.upsert_instruction("global", InstructionUpsert(content="keep this")) + + agent_settings.update_settings( + AgentSettings( + default_model_id=encode_model_id("openai", "new"), + default_memory_enabled=False, + default_memory_backend="file", + global_mcp_auto_attach=False, + global_skill_auto_attach=True, + ) + ) + + assert model_link._load()["default_model"] == "openai/new" + assert instructions.get_instruction("global").content == "keep this" + + +def test_probe_stdio(): + assert mcp_health._probe_stdio({"command": "python3"}) is True + assert mcp_health._probe_stdio({"command": "definitely-not-a-real-cmd-xyz"}) is False + assert mcp_health._probe_stdio({"url": "http://x"}) is True # not a stdio server + + +async def test_filter_healthy_drops_missing_stdio(): + servers = {"good": {"command": "python3"}, "bad": {"command": "nope-xyz-cmd"}} + healthy = await mcp_health.filter_healthy(servers, timeout=1.0) + assert "good" in healthy and "bad" not in healthy + + +async def test_check_server_reports_reason(): + ok, err = await mcp_health.check_server({"command": "python3"}) + assert ok is True and err is None + ok, err = await mcp_health.check_server({"command": "nope-xyz-cmd"}) + assert ok is False and "not found" in err + + +def test_mcp_health_adapter_probes_enabled_servers(): + good = mcps.create_mcp( + McpCreate(name="good-tool", transport="stdio", endpoint="python3 -m x", scope="global") + ) + bad = mcps.create_mcp( + McpCreate(name="bad-tool", transport="stdio", endpoint="nope-xyz-cmd -m y", scope="global") + ) + try: + rows = {h.id: h for h in mcps.health()} + assert rows[good.id].healthy is True and rows[good.id].error is None + assert rows[bad.id].healthy is False and rows[bad.id].error + finally: + mcps.delete_mcp(good.id) + mcps.delete_mcp(bad.id) + + +def test_session_naming_helpers(): + assert common._is_default_name("Session abc123") + assert common._is_default_name("") + assert not common._is_default_name("帮我写代码") + assert common._title_from_text("hello\nworld") == "hello" + assert common._title_from_text(" ") == "" + assert len(common._title_from_text("x" * 100)) == 40 + + +def test_session_messages_reads_persisted_user_and_assistant_only(): + project = common.pm().get_default_project() + sm = common.sm_for(project) + session = sm.create() + log = sm.get_session_log(session) + log.append({"role": "system", "content": "hidden"}) + log.append({"role": "user", "content": "hello"}) + log.append({"role": "assistant", "content": "hi"}) + log.append({"role": "tool", "content": "tool result"}) + + rows = sessions.list_messages(session.id) + + assert [(r.role, r.content) for r in rows] == [ + ("user", "hello"), + ("assistant", "hi"), + ] + + +def test_mcp_id_decode_errors_return_not_found_and_stdio_round_trips(): + with pytest.raises(NotFound): + mcps.get_mcp("not-base64") + + row = mcps.create_mcp( + McpCreate( + name="local-tool", + transport="stdio", + endpoint="python3 -m demo 'arg with space'", + scope="global", + ) + ) + assert row.endpoint == "python3 -m demo 'arg with space'" + + with pytest.raises(Conflict): + mcps.create_mcp( + McpCreate( + name="local-tool", + transport="stdio", + endpoint="python3 -m other", + scope="global", + ) + ) + + other = mcps.create_mcp( + McpCreate( + name="other-tool", + transport="stdio", + endpoint="python3 -m other", + scope="global", + ) + ) + with pytest.raises(Conflict): + mcps.update_mcp(other.id, McpUpdate(name="local-tool")) + + +def test_skill_source_requires_existing_directory(tmp_path): + with pytest.raises(BadRequest): + skills.create_skill( + SkillCreate( + name="missing", + kind="source", + content=str(tmp_path / "missing"), + scope="global", + ) + ) + + skill_dir = tmp_path / "demo-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: Demo Skill\ndescription: Demo skill description\n---\n\n# Demo\n", + encoding="utf-8", + ) + + created = skills.create_skill( + SkillCreate( + name="demo-skill", + kind="source", + content=str(skill_dir), + scope="global", + ) + ) + + assert created.name == "Demo Skill" + assert created.scope == "global" + + +def test_source_skill_disable_uses_runtime_skill_id(tmp_path): + from omegaconf import OmegaConf + + from ms_agent.skill.catalog import SkillCatalog + from ms_agent.tui.managed_config import merge_skills_into_config + + skill_dir = tmp_path / "runtime-skills" / "demo" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: Demo Runtime Skill\ndescription: Runtime visible skill\n---\n\n# Demo\n", + encoding="utf-8", + ) + + created = skills.create_skill( + SkillCreate( + name="demo-runtime", + kind="source", + content=str(skill_dir.parent), + scope="global", + ) + ) + assert created.enabled is True + + disabled = skills.update_skill(created.id, SkillUpdate(enabled=False)) + assert disabled.enabled is False + + skills_json = json.loads((Path(common.home()) / "skills.json").read_text()) + assert skills_json["disabled"] == ["demo"] + + project = common.pm().get_default_project() + cfg = config._apply_webui_defaults(OmegaConf.create({"tools": {}})) + cfg = merge_skills_into_config(cfg, common.home(), project.path) + catalog = SkillCatalog(config=cfg.skills) + catalog.load_from_config(cfg.skills) + + assert "demo" in catalog._skills + assert "demo" not in catalog.get_enabled_skills() + + +def test_skill_bundle_import_materializes_into_live_tree(): + content = json.dumps({ + "format": "webui.skill.bundle.v1", + "files": [ + { + "path": "writer/SKILL.md", + "content": ( + "---\n" + "name: Writer Skill\n" + "description: Helps write concise copy\n" + "---\n\n" + "# Writer\n" + ), + }, + {"path": "writer/references/style.md", "content": "# Style\n"}, + ], + }) + + created = skills.create_skill( + SkillCreate( + name="writer", + kind="bundle", + content=content, + scope="global", + ) + ) + + assert created.name == "Writer Skill" + # Materialized into the live tree — presence IS registration, so nothing + # is written to skills.json (which may not even exist). + skill_dir = Path(common.home()) / "skills" / "writer-skill" + assert (skill_dir / "SKILL.md").is_file() + assert (skill_dir / "references" / "style.md").is_file() + sj = Path(common.home()) / "skills.json" + if sj.exists(): + sources = json.loads(sj.read_text()).get("sources", []) + assert not any(Path(str(src)).name == "writer-skill" for src in sources) + assert any(row.name == "Writer Skill" for row in skills.list_skills("global")) + + +def test_live_tree_skill_discovered_and_deletable(): + """A skill dir dropped into /skills is listed without any skills.json + entry (presence = registration), and deleting it removes the directory.""" + tree_dir = Path(common.home()) / "skills" / "dropped-skill" + tree_dir.mkdir(parents=True) + (tree_dir / "SKILL.md").write_text( + "---\nname: Dropped Skill\ndescription: Appears by presence\n---\n\n# D\n", + encoding="utf-8", + ) + + rows = [r for r in skills.list_skills("global") if r.name == "Dropped Skill"] + assert rows and rows[0].id.startswith("src::") + + skills.delete_skill(rows[0].id) + assert not tree_dir.exists() + assert not any(r.name == "Dropped Skill" for r in skills.list_skills("global")) + + +def test_external_source_skill_delete_protected(tmp_path): + """Skills from an explicit source OUTSIDE the live tree stay delete-protected.""" + ext = tmp_path / "ext-skills" / "outside" + ext.mkdir(parents=True) + (ext / "SKILL.md").write_text( + "---\nname: Outside Skill\ndescription: External source\n---\n\n# O\n", + encoding="utf-8", + ) + skills.create_skill( + SkillCreate(name="outside", kind="source", content=str(ext.parent), scope="global") + ) + rows = [r for r in skills.list_skills("global") if r.name == "Outside Skill"] + assert rows + with pytest.raises(BadRequest): + skills.delete_skill(rows[0].id) + assert ext.exists() + + +def test_webui_defaults_enable_skill_runtime(): + """_apply_webui_defaults seeds skill-runtime defaults but no longer injects + builtin tools — those come from settings.json via the SDK resolver now.""" + from omegaconf import OmegaConf + + cfg = config._apply_webui_defaults(OmegaConf.create({"tools": {}})) + + assert cfg.skills.prompt_injection == "all" + assert cfg.skills.auto_discover is True + assert cfg.skills.enable_manage is False + # Tools are left untouched here (no file_system/todo_list injection). + assert "file_system" not in cfg.tools + + +def test_seed_tools_settings_writes_default_block(tmp_path): + """bootstrap seeds a full builtin-tools block into settings.json so tools are + default-enabled; code_executor runs local shell (gated by permission), + web_search is present but opt-in (enabled=False), and task_control (no UI + component) is not seeded.""" + from app.backends.ms_agent import bootstrap + + bootstrap._seed_tools_settings(str(tmp_path)) + tools = json.loads((tmp_path / "settings.json").read_text())["tools"] + + assert tools["file_system"]["mcp"] is False + assert tools["file_system"]["include"] == [ + "read_file", "grep", "glob", "edit_file", "write_file", + ] + assert tools["todo_list"]["mcp"] is False + assert tools["code_executor"]["implementation"] == "python_env" + assert tools["code_executor"]["include"] == ["shell_executor"] # terminal only + assert tools["web_search"]["enabled"] is False + assert "task_control" not in tools + + # Migration on an existing block: keep user tools untouched, drop retired + # defaults (task_control), add newly-introduced defaults (code_executor), + # and narrow an un-customized code_executor to shell-only. + (tmp_path / "settings.json").write_text(json.dumps( + {"tools": {"todo_list": {"user_edit": 1}, "task_control": {"mcp": False}, + "code_executor": {"mcp": False, "implementation": "python_env"}}})) + bootstrap._seed_tools_settings(str(tmp_path)) + migrated = json.loads((tmp_path / "settings.json").read_text())["tools"] + assert "task_control" not in migrated # retired -> dropped + assert migrated["todo_list"] == {"user_edit": 1} # user config preserved + assert migrated["code_executor"]["include"] == ["shell_executor"] # narrowed + assert migrated["code_executor"]["implementation"] == "python_env" # new default added + + +def test_settings_tools_disable_resolves_through_config(tmp_path): + """settings.json `tools..enabled: false` survives the SDK multi-level + resolve, so a higher config layer can turn a seeded builtin tool off.""" + from ms_agent.config import ConfigResolver + + (tmp_path / "settings.json").write_text(json.dumps({ + "tools": { + "file_system": {"mcp": False}, + "web_search": {"mcp": False, "enabled": False}, + } + })) + + cfg = ConfigResolver(global_dir=str(tmp_path)).resolve() + + assert "file_system" in cfg.tools + assert cfg.tools.web_search.enabled is False + + +def test_webui_generation_params_are_applied_to_runtime_config(): + from omegaconf import OmegaConf + + from app.backends.ms_agent import sidecar + from app.backends.ms_agent.mapping import encode_model_id + + provider = "testprov-gen" + model = "kimi-k2.5" + model_id = encode_model_id(provider, model) + sidecar.merge( + "providers", + provider, + {"default_generation_params": {"max_tokens": 123, "temperature": 0.8}}, + ) + sidecar.merge("models", model_id, {"advanced_params": {"top_p": 0.9}}) + + cfg = OmegaConf.create({ + "llm": {"service": provider, "model": model}, + "generation_config": {"temperature": 0.3, "extra_body": {"enable_thinking": True}}, + }) + + cfg = config._apply_webui_generation_params(cfg) + cfg = config._apply_model_compatibility(cfg) + + assert cfg.generation_config.max_tokens == 123 + assert cfg.generation_config.top_p == 0.9 + assert cfg.generation_config.temperature == 1.0 + assert cfg.generation_config.extra_body.enable_thinking is False + + +def test_user_thinking_param_overrides_provider_default_off(): + """A non-Qwen provider defaults enable_thinking off, but an explicit user + thinking param (per-provider thinking control) must win (#5).""" + from omegaconf import OmegaConf + + from app.backends.ms_agent import sidecar + from app.backends.ms_agent.mapping import encode_model_id + + provider = "kimi-think" + model = "kimi-k2.5" + sidecar.merge( + "models", + encode_model_id(provider, model), + {"advanced_params": {"extra_body": {"enable_thinking": True}}}, + ) + cfg = OmegaConf.create({ + "llm": {"service": provider, "model": model}, + "generation_config": {"extra_body": {"enable_thinking": True}}, + }) + cfg = config._apply_webui_generation_params(cfg) + cfg = config._apply_model_compatibility(cfg) + + # Without the user param this provider would be forced to False; it wins here. + assert cfg.generation_config.extra_body.enable_thinking is True + + +def test_sdk_default_temperature_is_removed_unless_webui_explicit(): + from omegaconf import OmegaConf + + cfg = OmegaConf.create({ + "llm": {"service": "plain-provider", "model": "plain-model"}, + "generation_config": {"temperature": 0.3, "stream": True}, + }) + + cfg = config._apply_model_compatibility(cfg) + + assert "temperature" not in cfg.generation_config + assert cfg.generation_config.stream is True diff --git a/webui/backend/tests/test_providers.py b/webui/backend/tests/test_providers.py new file mode 100644 index 000000000..a536710c2 --- /dev/null +++ b/webui/backend/tests/test_providers.py @@ -0,0 +1,57 @@ +"""Built-in provider catalog: the SDK registry is the 'fill an API key' source. + +Item I (webui-remain01 §5): a user should be able to pick a built-in provider +and just enter a key. That works because every ProviderSpec already ships a +default base_url + transport, so only the key is user-supplied. This locks in +that the full catalog is present and self-describing. +""" +from ms_agent.llm.spec import get_registry + +_EXPECTED_BUILTINS = { + "openai", "anthropic", "google", "modelscope", "zhipu", + "kimi", "deepseek", "dashscope", "minimax", "openrouter", +} + + +def test_registry_ships_full_builtin_catalog(): + names = {p.name for p in get_registry().list_providers()} + assert _EXPECTED_BUILTINS <= names + + +def test_every_builtin_spec_is_key_only_ready(): + # default_base_url + transport present => only the API key is user-supplied. + for spec in get_registry().list_providers(): + assert spec.default_base_url, f"{spec.name} missing default_base_url" + assert spec.transport, f"{spec.name} missing transport" + + +def test_available_models_returns_the_discovered_ids(monkeypatch): + """Regression: the endpoint fell off its own end without returning, so + FastAPI validated None against its declared list[str] and answered 500 — the + "add model" dialog's id autocomplete was silently always empty. + """ + from app.api.providers import available_models + from app.core import model_discovery + + seen: dict = {} + + def _fake(base_url: str, protocol: str, api_key: str) -> list[str]: + seen.update(base_url=base_url, protocol=protocol) + return ["gpt-4o", "gpt-4o-mini"] + + monkeypatch.setattr(model_discovery, "fetch_model_ids", _fake) + assert available_models("openai") == ["gpt-4o", "gpt-4o-mini"] + # Called with the provider's own resolved endpoint + protocol, not defaults. + assert seen["base_url"] and seen["protocol"] == "openai" + + +def test_available_models_passes_through_the_empty_degraded_case(monkeypatch): + """Discovery is best-effort: fetch_model_ids answers [] for a missing key / + network error / non-standard endpoint, and that [] must reach the client as a + valid empty response (the UI then offers free-form entry).""" + from app.api.providers import available_models + from app.core import model_discovery + + monkeypatch.setattr(model_discovery, "fetch_model_ids", + lambda *_a, **_k: []) + assert available_models("modelscope") == [] diff --git a/webui/backend/tests/test_sessions_reconstruct.py b/webui/backend/tests/test_sessions_reconstruct.py new file mode 100644 index 000000000..04f00cd62 --- /dev/null +++ b/webui/backend/tests/test_sessions_reconstruct.py @@ -0,0 +1,144 @@ +"""History reconstruction: attachment block -> structured user files.""" +from types import SimpleNamespace + +from app.backends.ms_agent.sessions import ( + _attached_files, + _reconstruct, + _split_attached, +) + + +def _project(path): + return SimpleNamespace(id="p1", path=str(path)) + + +def test_split_attached_none(): + assert _split_attached("just text") == ("just text", []) + + +def test_split_attached_text_and_paths(): + content = ( + "summarize this\n\n" + "[Attached files] (paths are relative to the project workspace root; " + "use the file tools to read them):\n" + "- user_files/a.txt\n" + "- user_files/b.png\n" + ) + text, paths = _split_attached(content) + assert text == "summarize this" + assert paths == ["user_files/a.txt", "user_files/b.png"] + + +def test_split_attached_files_only(): + content = ( + "[Attached files] (paths are relative to the project workspace root; " + "use the file tools to read them):\n" + "- user_files/only.pdf\n" + ) + text, paths = _split_attached(content) + assert text == "" + assert paths == ["user_files/only.pdf"] + + +def test_attached_files_exists_flag_and_kind(tmp_path): + (tmp_path / "user_files").mkdir() + (tmp_path / "user_files" / "here.png").write_bytes(b"x") + files = _attached_files( + _project(tmp_path), ["user_files/here.png", "user_files/gone.txt"] + ) + here, gone = files + assert here.name == "here.png" and here.exists is True and here.type == "image" + assert here.size == 1 # one byte written + assert here.url == "/api/projects/p1/workspace/files/user_files/here.png/raw" + assert gone.name == "gone.txt" and gone.exists is False and gone.type == "file" + assert gone.size is None + + +def test_reconstruct_user_message_strips_block(tmp_path): + (tmp_path / "user_files").mkdir() + (tmp_path / "user_files" / "a.txt").write_text("hi") + rows = [ + { + "seq": 0, + "role": "user", + "content": ( + "1\n\n[Attached files] (paths are relative to the project " + "workspace root; use the file tools to read them):\n" + "- user_files/a.txt\n" + ), + } + ] + msgs = _reconstruct(rows, _project(tmp_path)) + assert len(msgs) == 1 + m = msgs[0] + assert m.role == "user" + assert m.content == "1" # attachment block stripped from display text + assert len(m.files) == 1 and m.files[0].path == "user_files/a.txt" + assert m.files[0].exists is True + + +def test_reconstruct_plain_user_message_unchanged(tmp_path): + rows = [{"seq": 0, "role": "user", "content": "hello world"}] + msgs = _reconstruct(rows, _project(tmp_path)) + assert msgs[0].content == "hello world" + assert msgs[0].files == [] + + +def _file_step(msgs, kind): + for m in msgs: + for p in m.parts: + if p.kind == "step" and p.step and p.step.kind == kind: + return p.step + return None + + +def test_reconstruct_file_read_step_exists_flag(tmp_path): + """A file_read step is tagged with whether the workspace file still exists, + so the frontend can open it (present) or show a deleted card (gone).""" + (tmp_path / "kept.md").write_text("# hi") + rows = [ + { + "seq": 0, + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---read_file", + "arguments": '{"path": "kept.md"}', + }, + { + "id": "c2", + "tool_name": "file_system---write_file", + "arguments": '{"path": "gone.md"}', + }, + ], + } + ] + msgs = _reconstruct(rows, _project(tmp_path)) + read = _file_step(msgs, "file_read") + write = _file_step(msgs, "file_write") + assert read is not None and read.meta["path"] == "kept.md" + assert read.meta["exists"] is True + assert write is not None and write.meta["path"] == "gone.md" + assert write.meta["exists"] is False + + +def test_reconstruct_file_step_no_project_omits_exists(tmp_path): + """Without a project (legacy call), file steps don't carry an exists flag.""" + rows = [ + { + "seq": 0, + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "tool_name": "file_system---read_file", + "arguments": '{"path": "kept.md"}', + } + ], + } + ] + read = _file_step(_reconstruct(rows), "file_read") + assert read is not None and "exists" not in read.meta diff --git a/webui/backend/tests/test_settings_loading.py b/webui/backend/tests/test_settings_loading.py new file mode 100644 index 000000000..2a1816378 --- /dev/null +++ b/webui/backend/tests/test_settings_loading.py @@ -0,0 +1,66 @@ +"""Configuration precedence used by the source-checkout launcher.""" + +from app.core.settings import Settings + + +def test_dotenv_files_progress_from_repository_to_backend(tmp_path, monkeypatch): + repository_env = tmp_path / "repository.env" + webui_env = tmp_path / "webui.env" + backend_env = tmp_path / "backend.env" + repository_env.write_text("OPENAI_BASE_URL=https://repository.example\n") + webui_env.write_text("OPENAI_BASE_URL=https://webui.example\n") + backend_env.write_text("OPENAI_BASE_URL=https://backend.example\n") + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + configured = Settings( + _env_file=(repository_env, webui_env, backend_env), + _env_file_encoding="utf-8", + ) + + assert configured.openai_base_url == "https://backend.example" + + +def test_process_environment_wins_over_all_dotenv_files(tmp_path, monkeypatch): + backend_env = tmp_path / "backend.env" + backend_env.write_text("OPENAI_BASE_URL=https://backend.example\n") + monkeypatch.setenv("OPENAI_BASE_URL", "https://process.example") + + configured = Settings( + _env_file=(backend_env,), + _env_file_encoding="utf-8", + ) + + assert configured.openai_base_url == "https://process.example" + + +def test_app_wires_the_env_chain_for_its_layout(): + """Pin the APP's wiring, not pydantic's semantics. + + The two tests above hand-build ``Settings(_env_file=...)``, so they stay + green even if ``app/core/settings.py`` reverses the tuple or drops a + layer. This asserts the actual module-level chain for whichever layout + this checkout is in: the standalone repository (``/backend``) reads + repo + backend, and the same file embedded in the ms-agent repository + (``/webui/backend``) inserts the webui level and anchors the + repository root one directory higher. + """ + import app.core.settings as s + + backend_dir = s._BACKEND_DIR + assert backend_dir.name == "backend" + assert (backend_dir / "app" / "core" / "settings.py").is_file() + + if backend_dir.parent.name == "webui": # embedded in the ms-agent repo + expected = ( + backend_dir.parent.parent / ".env", + backend_dir.parent / ".env", + backend_dir / ".env", + ) + else: # standalone checkout + expected = ( + backend_dir.parent / ".env", + backend_dir / ".env", + ) + assert s._ENV_FILES == expected + assert s.Settings.model_config["env_file"] == tuple( + str(path) for path in expected) diff --git a/webui/backend/tests/test_skill_notice.py b/webui/backend/tests/test_skill_notice.py new file mode 100644 index 000000000..40977e463 --- /dev/null +++ b/webui/backend/tests/test_skill_notice.py @@ -0,0 +1,179 @@ +"""Skill-update notice lifecycle: surface diffing, sidecar commit semantics, +and the five-phase state walk from the design doc.""" +import time +import uuid +from pathlib import Path + +import pytest +from omegaconf import OmegaConf + +from app.backends.ms_agent import skill_notice +from app.backends.ms_agent.config import session_dir +from app.backends.ms_agent.skill_notice import build_surface, pending_notice + + +@pytest.fixture(autouse=True) +def _isolated_user_tree(tmp_path, monkeypatch): + """Keep the catalog's implicit /skills scan out of these tests — + other suites materialize skills into the shared test home.""" + import ms_agent.skill.catalog as cat_mod + + monkeypatch.setattr(cat_mod, "USER_SKILLS_DIR", tmp_path / "_no_user_tree") + + +def _proj_sess(): + """Unique ids per test — the sidecar lives under MS_AGENT_HOME, which the + conftest scopes per run, not per test.""" + proj = type("P", (), {"id": f"p-{uuid.uuid4().hex[:8]}", "path": ""})() + sess_id = f"s-{uuid.uuid4().hex[:8]}" + sess = type("S", (), {"id": sess_id, "session_key": sess_id})() + return proj, sess + + +def _mk_skill(root: Path, skill_id: str, desc: str = "d") -> Path: + d = root / skill_id + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f'---\nname: {skill_id}\ndescription: "{desc}"\n---\n# {skill_id}\n', + encoding="utf-8", + ) + return d + + +def _catalog(*dirs): + from ms_agent.skill.catalog import SkillCatalog + + cfg = OmegaConf.create( + {"sources": [{"type": "local", "path": str(d)} for d in dirs]}) + cat = SkillCatalog(config=cfg) + cat.load_from_config(cfg) + return cat + + +def test_brand_new_session_inits_silently(tmp_path): + proj, sess = _proj_sess() + cat = _catalog(_mk_skill(tmp_path, "alpha").parent) + + notice, commit = pending_notice(cat, proj, sess) + assert notice is None # head is fresh by construction — no announcement + # silent init already persisted the surface + surface_file = Path(session_dir(proj, sess)) / "skill_surface.json" + assert surface_file.exists() + + # next turn, unchanged: still silent + notice, _ = pending_notice(cat, proj, sess) + assert notice is None + + +def test_legacy_session_without_sidecar_gets_full_notice(tmp_path, monkeypatch): + proj, sess = _proj_sess() + # A session that predates the sidecar: history exists, surface unknown. + monkeypatch.setattr(skill_notice, "session_has_history", lambda p, s: True) + cat = _catalog(_mk_skill(tmp_path, "alpha").parent) + + notice, commit = pending_notice(cat, proj, sess) + assert notice is not None + assert "may have changed since this session started" in notice + assert "alpha" in notice + assert "Do not mention this notice to the user." in notice + + # not committed yet → re-fires + notice2, _ = pending_notice(cat, proj, sess) + assert notice2 is not None + + commit() # turn enqueued → persist + notice3, _ = pending_notice(cat, proj, sess) + assert notice3 is None + + +def test_add_remove_and_content_update_lines(tmp_path): + proj, sess = _proj_sess() + tree = tmp_path / "tree" + _mk_skill(tree, "alpha") + _mk_skill(tree, "beta") + cat = _catalog(tree) + _, commit = pending_notice(cat, proj, sess) + commit() + + # remove beta, add gamma, touch alpha's references/ + import shutil + + shutil.rmtree(tree / "beta") + _mk_skill(tree, "gamma") + ref = tree / "alpha" / "references" + ref.mkdir() + (ref / "guide.md").write_text("# guide\n", encoding="utf-8") + + cat2 = _catalog(tree) + notice, commit2 = pending_notice(cat2, proj, sess) + assert notice is not None + assert "Newly added since last known state: gamma" in notice + assert "Removed or disabled since last known state: beta" in notice + assert "Content updated since last known state: alpha" in notice + assert "re-read it via skill_view" in notice + commit2() + + notice2, _ = pending_notice(cat2, proj, sess) + assert notice2 is None + + +def test_reference_file_edit_alone_triggers_notice(tmp_path): + proj, sess = _proj_sess() + tree = tmp_path / "tree" + skill = _mk_skill(tree, "alpha") + ref = skill / "references" + ref.mkdir() + target = ref / "guide.md" + target.write_text("v1", encoding="utf-8") + + cat = _catalog(tree) + _, commit = pending_notice(cat, proj, sess) + commit() + + # edit only the reference file (mtime/size change) + time.sleep(0.01) + target.write_text("v2 — longer content", encoding="utf-8") + + notice, _ = pending_notice(_catalog(tree), proj, sess) + assert notice is not None + assert "Content updated since last known state: alpha" in notice + + +def test_description_edit_triggers_notice(tmp_path): + proj, sess = _proj_sess() + tree = tmp_path / "tree" + _mk_skill(tree, "alpha", desc="old description") + cat = _catalog(tree) + _, commit = pending_notice(cat, proj, sess) + commit() + + _mk_skill(tree, "alpha", desc="new description") + notice, _ = pending_notice(_catalog(tree), proj, sess) + assert notice is not None + assert "Content updated since last known state: alpha" in notice + + +def test_all_skills_removed_renders_empty_list(tmp_path): + proj, sess = _proj_sess() + tree = tmp_path / "tree" + _mk_skill(tree, "alpha") + _, commit = pending_notice(_catalog(tree), proj, sess) + commit() + + import shutil + + shutil.rmtree(tree / "alpha") + notice, _ = pending_notice(_catalog(tree), proj, sess) + assert notice is not None + assert "(no skills are currently available)" in notice + assert "Removed or disabled since last known state: alpha" in notice + + +def test_surface_tracks_enabled_only(tmp_path): + tree = tmp_path / "tree" + _mk_skill(tree, "alpha") + _mk_skill(tree, "beta") + cat = _catalog(tree) + cat.disable_skill("beta") + surface = build_surface(cat) + assert set(surface) == {"alpha"} diff --git a/webui/backend/tests/test_titler.py b/webui/backend/tests/test_titler.py new file mode 100644 index 000000000..3b87ac850 --- /dev/null +++ b/webui/backend/tests/test_titler.py @@ -0,0 +1,37 @@ +"""Titler parsing + config resolution (offline; no network).""" +from app.backends.ms_agent import titler + + +def test_parse_valid_json(): + out = titler._parse('{"title": "Plan a trip abroad", "category": "planning"}') + assert out == ("Plan a trip abroad", "planning") + + +def test_parse_strips_code_fence_and_quotes(): + raw = '```json\n{"title": "\\"Fix login bug\\"", "category": "coding"}\n```' + title, category = titler._parse(raw) + assert title == "Fix login bug" + assert category == "coding" + + +def test_parse_unknown_category_falls_back_to_general(): + out = titler._parse('{"title": "随便聊聊", "category": "banana"}') + assert out == ("随便聊聊", "general") + + +def test_parse_empty_title_returns_none(): + assert titler._parse('{"title": "", "category": "coding"}') is None + + +def test_parse_non_json_returns_none(): + assert titler._parse("sorry, I cannot help") is None + + +async def test_generate_returns_none_without_credentials(monkeypatch): + # No model/key/base_url resolved -> no network call, graceful None. + monkeypatch.setattr(titler, "_llm_config", lambda: ("", "", "")) + assert await titler.generate_title_and_category("hello") is None + + +async def test_generate_returns_none_for_blank_text(): + assert await titler.generate_title_and_category(" ") is None diff --git a/webui/backend/tests/test_workspace.py b/webui/backend/tests/test_workspace.py new file mode 100644 index 000000000..3738d03a9 --- /dev/null +++ b/webui/backend/tests/test_workspace.py @@ -0,0 +1,239 @@ +"""Workspace adapter: binary-safe upload + type-aware read (ms_agent backend). + +Guards the upload regression where files were persisted empty and binary +content was corrupted by UTF-8 coercion. Uses the real on-disk SDK workspace +(conftest isolates MS_AGENT_HOME), no LLM/network needed. +""" +import re + +from app.backends.ms_agent import workspace +from app.backends.ms_agent.bootstrap import bootstrap +from app.backends.ms_agent.projects import create_project +from app.schemas.project import ProjectCreate + +# 1x1 transparent PNG — real binary bytes that are NOT valid UTF-8. +_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" + "890000000a49444154789c6360000002000100ffff03000006000557bfabd400" + "00000049454e44ae426082" +) + + +def _new_project(name: str) -> str: + bootstrap() + return create_project(ProjectCreate(name=name)).id + + +def test_upload_text_roundtrips_content_and_type(): + pid = _new_project("ws-text") + body = "hello\nworld\n" + workspace.save_upload(pid, "notes/readme.md", body.encode("utf-8")) + + got = workspace.get_file(pid, "notes/readme.md") + assert got.content == body # full text is returned for editing + assert got.size == len(body.encode()) + assert got.content_type == "text/markdown" + + +def test_upload_binary_is_not_corrupted_and_has_no_text_content(): + pid = _new_project("ws-binary") + workspace.save_upload(pid, "logo.png", _PNG) + + got = workspace.get_file(pid, "logo.png") + # Undecodable -> the editor must not try to render it as text. + assert got.content is None + assert got.content_type == "image/png" + + # Raw bytes survive the round-trip verbatim (no UTF-8 mangling). + target, mime = workspace.raw_file(pid, "logo.png") + assert mime == "image/png" + assert target.read_bytes() == _PNG + + +def test_upload_overwrites_same_path(): + pid = _new_project("ws-overwrite") + workspace.save_upload(pid, "a.txt", b"one") + workspace.save_upload(pid, "a.txt", b"two") + assert workspace.get_file(pid, "a.txt").content == "two" + + +def test_dedup_upload_keeps_first_name_timestamps_rest(): + pid = _new_project("ws-dedup") + first = workspace.save_upload(pid, "user_files/a.txt", b"one", dedup=True) + second = workspace.save_upload(pid, "user_files/a.txt", b"two", dedup=True) + # First upload keeps the plain name; a later same-named upload with + # DIFFERENT bytes is timestamped (-.ext) so it never clobbers it. + assert first.path == "user_files/a.txt" + assert re.fullmatch(r"user_files/a-\d+(?:-\d+)?\.txt", second.path) + assert first.path != second.path + assert workspace.get_file(pid, "user_files/a.txt").content == "one" + assert workspace.get_file(pid, second.path).content == "two" + + +def test_dedup_upload_reuses_identical_bytes(): + pid = _new_project("ws-dedup-same") + first = workspace.save_upload(pid, "user_files/a.txt", b"same", dedup=True) + second = workspace.save_upload(pid, "user_files/a.txt", b"same", dedup=True) + # Re-uploading the exact same file reuses the first path (no new file). + assert first.path == second.path == "user_files/a.txt" + + +def test_listing_carries_content_type(): + pid = _new_project("ws-list") + workspace.save_upload(pid, "data.json", b"{}") + files = {f.path: f for f in workspace.list_files(pid)} + assert files["data.json"].content_type == "application/json" + + +def test_archive_is_never_inlined_as_text_even_if_decodable(): + # Regression: an archive whose bytes happen to be valid UTF-8 (e.g. one + # corrupted into a lossy text blob by an older buggy upload) must still be + # treated as binary, not poured into the editor. + pid = _new_project("ws-archive") + workspace.save_upload(pid, "archive.zip", b"PK totally decodable text") + got = workspace.get_file(pid, "archive.zip") + assert got.content is None + assert got.content_type == "application/zip" + + +def test_typescript_is_text_not_video(): + # mimetypes maps `.ts` -> video/mp2t; the override keeps it text so the + # frontend renders it in Monaco, not as a video element. + pid = _new_project("ws-ts") + workspace.save_upload(pid, "index.ts", b"export const x = 1\n") + got = workspace.get_file(pid, "index.ts") + assert got.content == "export const x = 1\n" + assert got.content_type == "text/typescript" + + +def test_move_renames_file_in_place(): + pid = _new_project("ws-mv-rename") + workspace.save_upload(pid, "a.txt", b"hi") + moved = workspace.move_file(pid, "a.txt", "b.txt") + assert moved.path == "b.txt" + assert workspace.get_file(pid, "b.txt").content == "hi" + paths = {f.path for f in workspace.list_files(pid)} + assert "a.txt" not in paths and "b.txt" in paths + + +def test_move_into_folder_carries_children(): + pid = _new_project("ws-mv-folder") + workspace.save_upload(pid, "src/one.txt", b"1") + workspace.save_upload(pid, "src/sub/two.txt", b"2") + workspace.move_file(pid, "src", "dst") + paths = {f.path for f in workspace.list_files(pid)} + assert "dst/one.txt" in paths and "dst/sub/two.txt" in paths + assert not any(p.startswith("src") for p in paths) + + +def test_move_rejects_existing_target(): + import pytest + from app.backends.errors import Conflict + + pid = _new_project("ws-mv-conflict") + workspace.save_upload(pid, "a.txt", b"a") + workspace.save_upload(pid, "b.txt", b"b") + with pytest.raises(Conflict): + workspace.move_file(pid, "a.txt", "b.txt") + + +def test_move_rejects_folder_into_own_subtree(): + import pytest + from app.backends.errors import BadRequest + + pid = _new_project("ws-mv-self") + workspace.save_upload(pid, "dir/f.txt", b"x") + with pytest.raises(BadRequest): + workspace.move_file(pid, "dir", "dir/child") + + +def test_listing_hides_framework_internal_dot_dirs(): + """Framework-internal dot dirs never surface in the listing — including the + LEGACY workspace-root spots older SDK tools littered (.locks etc.); a + user-facing dot dir like .github stays visible.""" + import pathlib + + pid = _new_project("ws-hidden") + root = pathlib.Path(workspace._project_path(pid)) + for d in (".locks", ".ms_agent_artifacts", ".index", ".temp"): + (root / d).mkdir(parents=True, exist_ok=True) + (root / d / "x.lock").write_text("x") + (root / ".github").mkdir(exist_ok=True) + (root / ".github" / "ci.yml").write_text("on: push") + (root / "kept.txt").write_text("k") + + paths = {f.path for f in workspace.list_files(pid)} + assert "kept.txt" in paths + assert ".github" in paths or ".github/ci.yml" in paths # user dot-dir kept + hidden = {".locks", ".ms_agent_artifacts", ".index", ".temp"} + assert not any(p.split("/")[0] in hidden for p in paths) + + +def test_listing_shows_ms_agent_and_user_memory_but_hides_dumps(): + """`.ms_agent` is visible (future permission files stay hand-manageable); + its pure-machinery subtrees (snapshots git store, transient locks) are + hidden wholesale; and under the VISIBLE memory/ dir the user's own memory + (MEMORY.md) shows while the SDK's .yaml/.json state dumps — the main + Agent-default AND agent-tool workers, any tag — stay hidden.""" + import pathlib + + pid = _new_project("ws-msa") + root = pathlib.Path(workspace._project_path(pid)) + # Visible: .ms_agent + a (future) permission json. + (root / ".ms_agent").mkdir(parents=True, exist_ok=True) + (root / ".ms_agent" / "permissions.json").write_text("{}") + # Machinery hidden wholesale. + (root / ".ms_agent" / "snapshots" / "objects").mkdir(parents=True, exist_ok=True) + (root / ".ms_agent" / "snapshots" / "objects" / "ab12").write_text("blob") + (root / ".ms_agent" / "locks").mkdir(parents=True, exist_ok=True) + (root / ".ms_agent" / "locks" / "plan.lock").write_text("x") + # memory/: user memory VISIBLE, save_history dumps (any tag) HIDDEN. + (root / ".ms_agent" / "memory").mkdir(parents=True, exist_ok=True) + (root / ".ms_agent" / "memory" / "MEMORY.md").write_text("- remembered") + (root / ".ms_agent" / "memory" / "Agent-default.yaml").write_text("llm: {}") + (root / ".ms_agent" / "memory" / "Agent-default.json").write_text("[]") + (root / ".ms_agent" / "memory" / "worker-a1b2c3d4.yaml").write_text("llm: {}") + (root / ".ms_agent" / "memory" / "worker-a1b2c3d4.json").write_text("[]") + + paths = {f.path for f in workspace.list_files(pid)} + assert ".ms_agent" in paths # the dir itself surfaces + assert ".ms_agent/permissions.json" in paths # manageable state visible + assert ".ms_agent/memory/MEMORY.md" in paths # user memory visible + # Machinery hidden. + assert not any( + p.startswith(".ms_agent/snapshots") or p.startswith(".ms_agent/locks") + for p in paths + ) + # save_history dumps (any tag) hidden, but the memory dir itself is fine. + assert not any( + p.endswith(".yaml") or p.endswith(".json") + for p in paths + if p.startswith(".ms_agent/memory/") + ) + + +def test_by_path_read_of_hidden_file_is_404(): + """A listing-hidden file (e.g. an .ms_agent/memory state dump, which embeds + API keys) must not be readable by direct path via get_file / raw_file — + otherwise hiding it from the tree would be cosmetic.""" + import pathlib + + from app.backends.errors import NotFound + + pid = _new_project("ws-bypath") + root = pathlib.Path(workspace._project_path(pid)) + (root / ".ms_agent" / "memory").mkdir(parents=True, exist_ok=True) + (root / ".ms_agent" / "memory" / "Agent-default.yaml").write_text( + "llm:\n deepseek_api_key: sk-secret\n" + ) + (root / "shown.txt").write_text("ok") + + dump = ".ms_agent/memory/Agent-default.yaml" + for fn in (workspace.get_file, workspace.raw_file): + try: + fn(pid, dump) + assert False, f"{fn.__name__} leaked a hidden file" + except NotFound: + pass + # A normal (visible) file still reads fine. + assert workspace.get_file(pid, "shown.txt").content == "ok" diff --git a/webui/backend/uv.lock b/webui/backend/uv.lock new file mode 100644 index 000000000..14b28ba4c --- /dev/null +++ b/webui/backend/uv.lock @@ -0,0 +1,3525 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" }, +] + +[[package]] +name = "anthropic" +version = "0.117.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl", hash = "sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b" } + +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b" }, +] + +[[package]] +name = "croniter" +version = "6.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/03/35/96ad0a71eb0b27ab4476a7ed23facd0713d82da9c911edc8af7f34a62d6a/croniter-6.2.3.tar.gz", hash = "sha256:fb129986ef7e2c44e3f4c9f503da83ad914d2afa48f40a43ee3dca4b5c41d476" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5c/dd/6466498a8b69754cffbd7237ce4c66446ca5ffcf53fb397d437666f056d2/croniter-6.2.3-py3-none-any.whl", hash = "sha256:137a97001b4d52fb71c10b750e303db79e6e42d40fff8ff77126102176c9f786" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9" }, +] + +[[package]] +name = "exa-py" +version = "2.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/68/a3/dabad8d3dbdb50d2ad49f426cc3e1fb47a0d0fd6ba10fbb27219c9bef684/exa_py-2.16.0.tar.gz", hash = "sha256:dce0db698720b1b7b39b58a1a17ad65fcc5969ee47f810cff5bcc836aeb0f777" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3c/c8/69d100ed12f8493ab8b9d03bf20cd0080a3a60dec4abadb1782cc4f91969/exa_py-2.16.0-py3-none-any.whl", hash = "sha256:cf726f801d6e4be25f38275a6318b536445a92077450a11cb3d7e493ea77ecc2" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189" }, +] + +[[package]] +name = "fastembed" +version = "0.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "loguru" }, + { name = "mmh3" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.14'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.14'" }, + { name = "py-rust-stemmers" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0" }, +] + +[[package]] +name = "filelock" +version = "3.29.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31" }, +] + +[[package]] +name = "grpcio" +version = "1.82.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.27.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.14'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.14'" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6" }, +] + +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, +] + +[[package]] +name = "mem0ai" +version = "2.0.12" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "httpx" }, + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/dd/41/4abe2bc4c369b88680c981e0ae81a12d7328024549c577fb929be9e4db5d/mem0ai-2.0.12.tar.gz", hash = "sha256:626c001faee2edd3733cbda7105571ed4a514c7c044bfe8f64fd48aa1b34dd7a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/db/6e/ae30d5fbadd09f4317ad765d67c229ffb915e7ee8120c52422cd3dabcdcc/mem0ai-2.0.12-py3-none-any.whl", hash = "sha256:6b7e1afa466f6e14dd34b5e9222c159a69fad38f8d787e73adbf91dbb29e73e2" }, +] + +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b" }, +] + +[[package]] +name = "modelscope" +version = "1.38.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "filelock" }, + { name = "modelscope-hub" }, + { name = "packaging" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "tqdm" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/33/72/b37f46f8e1900c64485420b210eecda1f758cbd0fe8ade60c5bbf883b25e/modelscope-1.38.1.tar.gz", hash = "sha256:a81f3685b1545f52b415d88a763456416aa65170e622f9dcc02eadb3f8e1cfa0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5d/f3/caf5ef8c4adc99cd66607639642ae503ac190a3358f782776687200a7c36/modelscope-1.38.1-py3-none-any.whl", hash = "sha256:7d65be96999144ca045386d27a8ea057b8777504c538d9755d60ec2c8906fe72" }, +] + +[[package]] +name = "modelscope-hub" +version = "0.1.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "filelock" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/cb/6b9d3fcbcd09db6cdd7f1e0fd60be2998c99db07be28f37982c0dbd14496/modelscope_hub-0.1.7.tar.gz", hash = "sha256:b78730f3923cf13d5fbc56c6224a5ba617bf111a562fe3808c03e34c3c07840f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/07/19/3554c99cfc633cca2ffab2cfc3c8cb064fa17f88bcb68afee7a81957ccc8/modelscope_hub-0.1.7-py3-none-any.whl", hash = "sha256:907e5da4d8b050277a3cbd79d1c2df4f723b549ba38f573f4ffb6d1cd1525349" }, +] + +[[package]] +name = "ms-agent" +version = "1.6.0" +source = { editable = "../../" } +dependencies = [ + { name = "aiohttp" }, + { name = "croniter" }, + { name = "dotenv" }, + { name = "json5" }, + { name = "markdown" }, + { name = "matplotlib" }, + { name = "mcp" }, + { name = "modelscope" }, + { name = "modelscope-hub" }, + { name = "numpy" }, + { name = "omegaconf" }, + { name = "openai" }, + { name = "pandas" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version < '3.14'" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }, marker = "python_full_version >= '3.14'" }, + { name = "prompt-toolkit" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a'", specifier = ">=0.3.25,<1.0.0" }, + { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'all'", specifier = ">=0.3.25,<1.0.0" }, + { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.9.0" }, + { name = "agent-client-protocol", marker = "extra == 'all'", specifier = ">=0.9.0" }, + { name = "aiohttp" }, + { name = "aiohttp", marker = "extra == 'all'" }, + { name = "arxiv", marker = "extra == 'all'" }, + { name = "arxiv", marker = "extra == 'research'" }, + { name = "croniter", specifier = ">=1.3.0" }, + { name = "croniter", marker = "extra == 'all'", specifier = ">=1.3.0" }, + { name = "docker", marker = "extra == 'all'" }, + { name = "docker", marker = "extra == 'code'" }, + { name = "docling", marker = "extra == 'all'", specifier = "<=2.38.1" }, + { name = "docling", marker = "extra == 'research'", specifier = "<=2.38.1" }, + { name = "docling-core", marker = "extra == 'all'", specifier = "<=2.38.2" }, + { name = "docling-core", marker = "extra == 'research'", specifier = "<=2.38.2" }, + { name = "docutils", marker = "extra == 'docs'", specifier = ">=0.16.0" }, + { name = "dotenv" }, + { name = "dotenv", marker = "extra == 'all'" }, + { name = "edge-tts", marker = "extra == 'all'" }, + { name = "edge-tts", marker = "extra == 'cinema'" }, + { name = "exa-py", marker = "extra == 'all'" }, + { name = "exa-py", marker = "extra == 'research'" }, + { name = "faiss-cpu", marker = "extra == 'all'" }, + { name = "faiss-cpu", marker = "extra == 'retrieval'" }, + { name = "google-search-results", marker = "extra == 'all'" }, + { name = "google-search-results", marker = "extra == 'research'" }, + { name = "gradio", marker = "extra == 'all'", specifier = ">=5.0.0" }, + { name = "gradio", marker = "extra == 'research'", specifier = ">=5.0.0" }, + { name = "json5" }, + { name = "json5", marker = "extra == 'all'" }, + { name = "json5", marker = "extra == 'research'" }, + { name = "llama-index-core", marker = "extra == 'all'" }, + { name = "llama-index-core", marker = "extra == 'code'" }, + { name = "llama-index-embeddings-huggingface", marker = "extra == 'all'" }, + { name = "llama-index-embeddings-huggingface", marker = "extra == 'code'" }, + { name = "markdown" }, + { name = "markdown", marker = "extra == 'all'" }, + { name = "markdown", marker = "extra == 'research'" }, + { name = "matplotlib" }, + { name = "matplotlib", marker = "extra == 'all'" }, + { name = "mcp" }, + { name = "mcp", marker = "extra == 'all'" }, + { name = "mcp", marker = "extra == 'research'" }, + { name = "mem0ai", marker = "extra == 'all'" }, + { name = "mem0ai", marker = "extra == 'code'" }, + { name = "modelscope", specifier = ">=1.35.2" }, + { name = "modelscope", marker = "extra == 'all'" }, + { name = "modelscope", marker = "extra == 'all'", specifier = ">=1.35.2" }, + { name = "modelscope", marker = "extra == 'research'" }, + { name = "modelscope-hub" }, + { name = "modelscope-hub", marker = "extra == 'all'" }, + { name = "moviepy", marker = "extra == 'all'" }, + { name = "moviepy", marker = "extra == 'cinema'" }, + { name = "myst-parser", marker = "extra == 'docs'" }, + { name = "numpy" }, + { name = "numpy", marker = "extra == 'all'" }, + { name = "omegaconf" }, + { name = "omegaconf", marker = "extra == 'all'" }, + { name = "openai" }, + { name = "openai", marker = "extra == 'all'" }, + { name = "openai", marker = "extra == 'research'" }, + { name = "pandas" }, + { name = "pandas", marker = "extra == 'all'" }, + { name = "pandas", marker = "extra == 'research'" }, + { name = "pillow" }, + { name = "pillow", marker = "extra == 'all'" }, + { name = "pillow", marker = "extra == 'research'" }, + { name = "prompt-toolkit" }, + { name = "prompt-toolkit", marker = "extra == 'all'" }, + { name = "python-dotenv", marker = "extra == 'all'" }, + { name = "python-dotenv", marker = "extra == 'research'" }, + { name = "pytz" }, + { name = "pytz", marker = "extra == 'all'" }, + { name = "pyyaml" }, + { name = "pyyaml", marker = "extra == 'all'" }, + { name = "recommonmark", marker = "extra == 'docs'" }, + { name = "requests" }, + { name = "requests", marker = "extra == 'all'" }, + { name = "requests", marker = "extra == 'research'" }, + { name = "rich" }, + { name = "rich", marker = "extra == 'all'" }, + { name = "rich", marker = "extra == 'research'" }, + { name = "sentence-transformers", marker = "extra == 'all'" }, + { name = "sentence-transformers", marker = "extra == 'retrieval'" }, + { name = "socksio", marker = "extra == 'all'" }, + { name = "socksio", marker = "extra == 'research'" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=5.3.0" }, + { name = "sphinx-book-theme", marker = "extra == 'docs'" }, + { name = "sphinx-copybutton", marker = "extra == 'docs'" }, + { name = "sphinx-design", marker = "extra == 'docs'" }, + { name = "sphinx-markdown-tables", marker = "extra == 'docs'" }, + { name = "sphinxawesome-theme", marker = "extra == 'docs'" }, + { name = "sphinxcontrib-mermaid", marker = "extra == 'docs'" }, + { name = "typing-extensions" }, + { name = "typing-extensions", marker = "extra == 'all'" }, + { name = "websocket-client", marker = "extra == 'all'" }, + { name = "websocket-client", marker = "extra == 'code'" }, +] +provides-extras = ["research", "code", "acp", "a2a", "retrieval", "cinema", "docs", "all"] + +[[package]] +name = "ms-agent-webui-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "anthropic" }, + { name = "exa-py" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jupyter-client" }, + { name = "loguru" }, + { name = "mem0ai" }, + { name = "ms-agent" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +local-embed = [ + { name = "fastembed" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.117.0" }, + { name = "exa-py", specifier = ">=2.16.0" }, + { name = "fastapi", specifier = ">=0.139.0" }, + { name = "fastembed", marker = "extra == 'local-embed'", specifier = ">=0.8" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "ipykernel", specifier = ">=7.3.0" }, + { name = "jupyter-client", specifier = ">=8.9.1" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "mem0ai", specifier = ">=2.0.12" }, + { name = "ms-agent", editable = "../../" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "sse-starlette", specifier = ">=3.4.5" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.50.2" }, +] +provides-extras = ["local-embed"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e" }, +] + +[[package]] +name = "openai" +version = "2.44.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/cd/4f25b2f95b23f5d2c9c1fe43e49841bff5800562149b2666afc09309aa8f/platformdirs-4.10.1.tar.gz", hash = "sha256:ceab4084426fe6319ce18e86deada8ab1b7487c7aee7040c55e277c9ae793695" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl", hash = "sha256:0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968" }, +] + +[[package]] +name = "posthog" +version = "7.24.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/54/49/8f0edc91b899c70a1f8e423385b2aba033975cae855b17f880d7cc7782e8/posthog-7.24.0.tar.gz", hash = "sha256:5aac3765fb96ae9af1bdf86e48f78f4e1ce7fd948febd640a9b7454f7e7f5a41" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4b/ed/f3e8c18a53781302aaba4efd95d90e1e557f69fa1f9d181651afe123ac6c/posthog-7.24.0-py3-none-any.whl", hash = "sha256:a249f317c7e96a4a73a11c6496a667ae190cae9b5513b01646e24f031717b3a5" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0" }, +] + +[[package]] +name = "py-rust-stemmers" +version = "0.1.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6b/c1/9763f9fb1cd73f9c317a83feeed6e0d4af320c6bbddab47b4a94f3a47d0c/py_rust_stemmers-0.1.8.tar.gz", hash = "sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e2/6a/39080bc8f4a441a35378c0faeeb834fb27974997f40d51342574e70f9662/py_rust_stemmers-0.1.8-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6a9a4b8733d0b307bd0879ab7e321aa8a0bfd054a75a5cb23c647df5ca7d17c3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/15/ae60b9010924adac465f418822d9c514690aba6846edd67b6e2b5c227745/py_rust_stemmers-0.1.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51d0042d2a92ef0f7048bfc06b6c2a02306af31ea47f09d24b34e4b7e63c4e80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/7c/94be8b932179823d66e0d2be03a94706132a7d16a640d5e5710de1cb1b8f/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d3d34094b9b6078a8ea6fe1c7044e5fd32f14e76c94818c5008f49ae075f08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/a4/8bd5c9f31207136830457d819e3f98bb21c54c0cdc40d6f1845ce4efdf7c/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:40c86be90cee4a709ad84fde4db7f11ca44d65630a56b77ec86fe84c23adfc09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/95/95da2b353b164a3a2b8a1c799866a58060693be4f1dc21065663dc67dc17/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:515884bcfb47b10335146648f276930d0c1201ae5e8b7b400fb46d8ea05c0ec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/ce/f34403b68808519dfa3220e1d94a40f26d5025f27e28893e2388ab9cfde5/py_rust_stemmers-0.1.8-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa42f5f8feb694aaaa869eedf477fcaf66f67a192cd64d94302d06920c33864a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/01/fb8527f6474d576975415405c985a97260e0403829e062103d334230b7d2/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2e86ad68fe297a6652f0f0390625ea81858b6f27862fd4c5ee1214bf5af29b9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/ac/73816237dbec20a7299abf901e2f7b6061d238754e033b48e423603f5336/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4b90fc81411943b114e8eb4988a876ba3b12bd2d20741559803eddc4131575dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/0a/dd48debf386a206ee1c6ad75a0827eac89428441291c90d98bc3803fccf1/py_rust_stemmers-0.1.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56cc2c2df742fa6529285b7d204720f34b7da789ed78eb578442f93c6de97d89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/ca/ebb707ab280636b8f46d040ccb051d1a9ddbc1f1ca2d90cdba626872f405/py_rust_stemmers-0.1.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd967eea2f808a1e73aa71ecccef0f4925a4cca4eb02ced94057afe3303153ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/98/f078f3930311e7b6154ccdf9166c4e30a416c7d199e136b5f09265d58a35/py_rust_stemmers-0.1.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/46/21d784a3f1db6a23051ffd5826d8ee667d26a64587c1cfbda0443ed87fff/py_rust_stemmers-0.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/d5/701c73a4f6a7fecfd96a6588f0cafe98d6b0acde93adf8a2e45535f3d1d5/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/0d/c58fe98153cfdb6abf4dfb6ac335c923000d4af4e736080c3a3045b7aea7/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/d7/e60d04849e90aa3ad457211cc4999c30401f433341f9a5588c12b81f9877/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/48/c0e4fb955db784cc354e0756354602f7043ff4c10fcbd9d901a2f8fe3239/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/eb/981b26baff37cf7a26ee206763cc4d2fb3e1db8f0f86ec030074431fae05/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/af/f16e805b7aefc2257b192b83a89300c8360b0fdffd3dfefa92dee4ec9b15/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/8c/e7a2c940ba00e0792ae346aed5e755d51d37cf6d6853f6b141e5380e285d/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/a0/dd7c5fc6ade6d2a2a49e49937f06f2d488511454e8ab1b313d277ee8c3b1/py_rust_stemmers-0.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/7e/f4346adfd44acbd7eaedcbd7d21b7f40ec9712e6c699e71fddad8dae6f8d/py_rust_stemmers-0.1.8-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:526b58958c6ffa36c4a805326cfb624ecbd665d16ba435027dbed0bcbcaa09d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/d8/988fc3f5dc0dbbd4bf5909f50ff953ab55ee8b5f79a835d00e57847d3123/py_rust_stemmers-0.1.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b607f0b270951fb66479baf4b68716cc63a981585cbd898b0b6b5c359efde7e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/94/e04c8b6a8364bca1b368785cef143755dd2d1ffe74df8f8b47b075bb1043/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0327b151ab8a338fb54fdac114ba34394327fc1e2c4c425ad1caf2013e5de3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/cb/f59f9a80caa099cb6625a46c9a8e6e7e80bb3ed284f17e80245c8240a66e/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dadd0e369703817fc7026987b3093f461f9f58d8dde74e689d546184bc8f3451" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/59/8211cd0f56e53f7770debd9a78de37985fb5662ae66e3b7b380f4c79888b/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:245e2c61c52e073341893a9682cd1396b61047154548aee30bb1af3d8ed4b4cc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/72/fe33e614c114264d1ba54d39da4b5a4abeb6aedd0d26e5a8fd0637d6ddba/py_rust_stemmers-0.1.8-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:451ee1c02a3f5cf1e161b46ba9032cdda4ba10a8b03ff9ee61c1d34d42a0bc81" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/f9/3cd18902fe2fa54557d3fe9132552256372d381c7aca71346163055d78b1/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d396dd25c473c1bc4248c79cd223f4b36356b55a124652f015c6a001547f81ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/d7/32c6d3995e7036b73683389de2771f4dbbf40de192b7efe73c2528ee1eb5/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:479c77c32d8be692f3cfcde7e19273f02ac81d6f45c6aef49887ef95cab7abbb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/8c/e68fa5d862ea6a27fced3535c25ea4eaa26ba1ce00dfef5841924c74b167/py_rust_stemmers-0.1.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c786235275c5c2abb7f206b8236aee3ca0bc53c7497daf7fb7b01d3491469547" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/48/aa584cf3772e01231641c95dc1aa73327a7d986c562639d78d0013733acf/py_rust_stemmers-0.1.8-cp314-cp314-win_amd64.whl", hash = "sha256:931d13570962b093417e5443a9d1bd63d73fa239ebb81e5b1d346663571403e4" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7" }, +] + +[[package]] +name = "qdrant-client" +version = "1.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d6/10/c437bd2ac41ef30d3019063e6ce537dc111e9214473b337ee88f7fa6359a/qdrant_client-1.18.0-py3-none-any.whl", hash = "sha256:093aa8cf8a420ee3ad2a68b007e1378d7992b2600e0b53c193fc172674f659cd" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107" }, + { url = "https://mirrors.aliyun.com/pypi/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972" }, + { url = "https://mirrors.aliyun.com/pypi/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796" }, +] + +[[package]] +name = "tqdm" +version = "4.68.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, +] + +[[package]] +name = "uvicorn" +version = "0.50.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925" }, + { url = "https://mirrors.aliyun.com/pypi/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" }, + { url = "https://mirrors.aliyun.com/pypi/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461" }, + { url = "https://mirrors.aliyun.com/pypi/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9" }, +] diff --git a/webui/backend/websocket_handler.py b/webui/backend/websocket_handler.py deleted file mode 100644 index 549d1f41c..000000000 --- a/webui/backend/websocket_handler.py +++ /dev/null @@ -1,495 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -WebSocket handler for real-time communication -Handles agent execution, log streaming, and progress updates. -""" -import asyncio -import json -import os -from agent_runner import AgentRunner -from datetime import datetime -from deep_research_worker_manager import DeepResearchWorkerManager -from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from pathlib import Path -# Import shared instances -from shared import config_manager, project_discovery, session_manager -from typing import Any, Dict, Set - -router = APIRouter() - - -class ConnectionManager: - """Manages WebSocket connections""" - - def __init__(self): - self.active_connections: Dict[str, Set[WebSocket]] = {} - self.log_connections: Set[WebSocket] = set() - - async def connect(self, websocket: WebSocket, session_id: str): - """Connect a client to a session""" - await websocket.accept() - if session_id not in self.active_connections: - self.active_connections[session_id] = set() - self.active_connections[session_id].add(websocket) - - async def connect_logs(self, websocket: WebSocket): - """Connect a client to log stream""" - await websocket.accept() - self.log_connections.add(websocket) - - def disconnect(self, websocket: WebSocket, session_id: str = None): - """Disconnect a client""" - if session_id and session_id in self.active_connections: - self.active_connections[session_id].discard(websocket) - if not self.active_connections[session_id]: - del self.active_connections[session_id] - self.log_connections.discard(websocket) - - async def send_to_session(self, session_id: str, message: Dict[str, Any]): - """Send message to all clients in a session""" - if session_id in self.active_connections: - disconnected = set() - for connection in self.active_connections[session_id]: - try: - await connection.send_json(message) - except Exception: - disconnected.add(connection) - for conn in disconnected: - self.active_connections[session_id].discard(conn) - - async def broadcast_log(self, log_entry: Dict[str, Any]): - """Broadcast log entry to all log connections""" - disconnected = set() - for connection in self.log_connections: - try: - await connection.send_json(log_entry) - except Exception: - disconnected.add(connection) - for conn in disconnected: - self.log_connections.discard(conn) - - -connection_manager = ConnectionManager() -agent_runners: Dict[str, AgentRunner] = {} -agent_tasks: Dict[str, asyncio.Task] = {} - - -async def _update_deep_research_status(session_id: str, - event: Dict[str, Any]) -> None: - event_type = event.get('type') - if event_type == 'status': - status = event.get('status') - if status: - session_manager.update_session(session_id, {'status': status}) - return - if event_type == 'complete': - session_manager.update_session(session_id, {'status': 'completed'}) - return - if event_type == 'error': - session_manager.update_session(session_id, {'status': 'error'}) - return - if event_type == 'dr.worker.exited': - payload = event.get('payload') or {} - status = payload.get('status') or 'completed' - session_manager.update_session(session_id, {'status': status}) - return - if event_type == 'dr.worker.error': - session_manager.update_session(session_id, {'status': 'error'}) - - -async def _send_deep_research_event(session_id: str, event: Dict[str, - Any]) -> None: - event_type = str(event.get('type') or '') - stored_event = event - if event_type.startswith('dr.'): - stored_event = session_manager.add_dr_event(session_id, event) or event - await connection_manager.send_to_session(session_id, stored_event) - await _update_deep_research_status(session_id, event) - - -deep_research_manager = DeepResearchWorkerManager(_send_deep_research_event) - - -@router.websocket('/session/{session_id}') -async def websocket_session(websocket: WebSocket, session_id: str): - """WebSocket endpoint for session communication""" - print(f'[WS] Client connecting to session: {session_id}') - await connection_manager.connect(websocket, session_id) - print(f'[WS] Client connected to session: {session_id}') - - try: - while True: - data = await websocket.receive_json() - print(f'[WS] Received message: {data}') - await handle_session_message(session_id, data, websocket) - except WebSocketDisconnect: - print(f'[WS] Client disconnected from session: {session_id}') - connection_manager.disconnect(websocket, session_id) - session = session_manager.get_session(session_id) - is_deep_research = bool( - session and session.get('project_id') == 'deep_research_v2') - # Stop agent if running - if session_id in agent_runners: - await agent_runners[session_id].stop() - del agent_runners[session_id] - if session_id in agent_tasks: - agent_tasks[session_id].cancel() - del agent_tasks[session_id] - if not is_deep_research: - await deep_research_manager.stop(session_id) - - -@router.websocket('/logs') -async def websocket_logs(websocket: WebSocket): - """WebSocket endpoint for log streaming""" - await connection_manager.connect_logs(websocket) - - try: - while True: - # Keep connection alive - await websocket.receive_text() - except WebSocketDisconnect: - connection_manager.disconnect(websocket) - - -async def handle_session_message(session_id: str, data: Dict[str, Any], - websocket: WebSocket): - """Handle incoming WebSocket messages""" - action = data.get('action') - - if action == 'start': - await start_agent(session_id, data, websocket) - elif action == 'stop': - await stop_agent(session_id) - elif action == 'send_input': - await send_input(session_id, data) - elif action == 'get_status': - await send_status(session_id, websocket) - - -async def start_agent(session_id: str, data: Dict[str, Any], - websocket: WebSocket): - """Start an agent for a session""" - print(f'[Agent] Starting agent for session: {session_id}') - - session = session_manager.get_session(session_id) - if not session: - print(f'[Agent] ERROR: Session not found: {session_id}') - await websocket.send_json({ - 'type': 'error', - 'message': 'Session not found' - }) - return - - session_type = session.get('session_type', 'project') - - # For chat mode, use default chat agent config - if session_type == 'chat': - # Create a virtual project for chat mode using the default agent.yaml - import ms_agent - - # Get ms_agent package installation path - # Use __path__ which is always available for packages and gives real filesystem paths - if hasattr(ms_agent, '__path__') and ms_agent.__path__: - ms_agent_package_path = Path(ms_agent.__path__[0]) - elif ms_agent.__file__ is not None: - ms_agent_package_path = Path(ms_agent.__file__).parent - else: - raise RuntimeError('Cannot determine ms_agent package path. ' - 'Please ensure ms_agent is properly installed.') - chat_config_path = ms_agent_package_path / 'agent' / 'agent.yaml' - - project = { - 'id': '__chat__', - 'name': 'Chat Assistant', - 'display_name': 'Chat Assistant', - 'description': 'Default chat mode', - 'type': 'agent', - 'path': str(ms_agent_package_path / 'agent'), - 'config_file': str(chat_config_path), - 'has_readme': False, - 'supports_workflow_switch': False - } - else: - # For project mode, get the project - project = project_discovery.get_project(session['project_id']) - if not project: - print(f"[Agent] ERROR: Project not found: {session['project_id']}") - await websocket.send_json({ - 'type': 'error', - 'message': 'Project not found' - }) - return - - # Clean up output directory for code_genesis before starting - if project['id'] == 'code_genesis': - output_dir = os.path.join(project['path'], 'output') - if os.path.exists(output_dir): - try: - import shutil - shutil.rmtree(output_dir) - print(f'[Agent] Cleaned up output directory: {output_dir}') - await connection_manager.send_to_session( - session_id, { - 'type': 'log', - 'level': 'info', - 'message': 'Cleaned up previous output directory', - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - print( - f'[Agent] WARNING: Failed to clean output directory: {e}' - ) - # Don't fail if cleanup fails, just log it - - # Get workflow_type from session (default to 'standard') - workflow_type = session.get('workflow_type', 'standard') - - print(f"[Agent] Project: {project['id']}, type: {project['type']}, " - f"config: {project['config_file']}, workflow_type: {workflow_type}") - - query = data.get('query', '') - print(f'[Agent] Query: {query[:100]}...' - if len(query) > 100 else f'[Agent] Query: {query}') - - # Add user message to session (but don't broadcast - frontend already has it) - session_manager.add_message(session_id, 'user', query, 'text') - - if project['id'] == 'deep_research_v2': - try: - backend_root = Path(__file__).resolve().parents[1] - output_dir = backend_root / 'work_dir' / session_id - await deep_research_manager.start( - session_id, - query=query, - config_path=project['config_file'], - output_dir=str(output_dir), - env_vars=config_manager.get_env_vars(), - llm_config=config_manager.get_llm_config(), - deep_research_config=config_manager.get_deep_research_config(), - ) - session_manager.update_session(session_id, {'status': 'running'}) - await connection_manager.send_to_session(session_id, { - 'type': 'status', - 'status': 'running' - }) - except Exception as e: - await connection_manager.send_to_session( - session_id, { - 'type': 'error', - 'message': f'Worker 启动失败: {str(e)}' - }) - session_manager.update_session(session_id, {'status': 'error'}) - return - - # Create agent runner with workflow_type - runner = AgentRunner( - session_id=session_id, - project=project, - config_manager=config_manager, - on_output=lambda msg: asyncio.create_task( - on_agent_output(session_id, msg)), - on_log=lambda log: asyncio.create_task(on_agent_log(session_id, log)), - on_progress=lambda prog: asyncio.create_task( - on_agent_progress(session_id, prog)), - on_complete=lambda result: asyncio.create_task( - on_agent_complete(session_id, result)), - on_error=lambda err: asyncio.create_task( - on_agent_error(session_id, err)), - workflow_type=workflow_type) - - agent_runners[session_id] = runner - session_manager.update_session(session_id, {'status': 'running'}) - - # Notify session started - await connection_manager.send_to_session(session_id, { - 'type': 'status', - 'status': 'running' - }) - - # Start agent in background so the WS loop can still receive stop/input messages - task = asyncio.create_task(runner.start(query)) - agent_tasks[session_id] = task - - def _cleanup(_task: asyncio.Task): - agent_tasks.pop(session_id, None) - - task.add_done_callback(_cleanup) - - -async def stop_agent(session_id: str): - """Stop a running agent""" - await deep_research_manager.stop(session_id) - if session_id in agent_runners: - await agent_runners[session_id].stop() - del agent_runners[session_id] - if session_id in agent_tasks: - agent_tasks[session_id].cancel() - del agent_tasks[session_id] - - session_manager.update_session(session_id, {'status': 'stopped'}) - await connection_manager.send_to_session(session_id, { - 'type': 'status', - 'status': 'stopped' - }) - - -async def send_input(session_id: str, data: Dict[str, Any]): - """Send input to a running agent""" - if session_id not in agent_runners: - print(f'[WS] ERROR: Agent runner not found for session: {session_id}') - await connection_manager.send_to_session( - session_id, { - 'type': - 'error', - 'message': - ('Agent is not running. The workflow may have completed. ' - 'Please start a new conversation or restart the agent.') - }) - return - - input_text = data.get('input', '') - print(f'[WS] Sending input to agent: {input_text[:100]}...') - - # Check if process is still alive - runner = agent_runners[session_id] - if runner.process and runner.process.returncode is not None: - print( - f'[WS] ERROR: Process has exited with code {runner.process.returncode}' - ) - await connection_manager.send_to_session( - session_id, { - 'type': - 'error', - 'message': - 'Agent process has terminated. The workflow completed. Please start a new conversation to continue.' - }) - # Clean up the runner - del agent_runners[session_id] - return - - # Update session status to running - session_manager.update_session(session_id, {'status': 'running'}) - await connection_manager.send_to_session(session_id, { - 'type': 'status', - 'status': 'running' - }) - - # Add user message to session - session_manager.add_message(session_id, 'user', input_text, 'text') - - # Send input to agent - try: - await runner.send_input(input_text) - except Exception as e: - print(f'[WS] ERROR: Failed to send input: {e}') - await connection_manager.send_to_session( - session_id, { - 'type': - 'error', - 'message': - f'Failed to send input: {str(e)}. The process may have terminated.' - }) - - -async def send_status(session_id: str, websocket: WebSocket): - """Send current status to a client""" - session = session_manager.get_session(session_id) - if session: - await websocket.send_json({ - 'type': - 'status', - 'session': - session, - 'messages': - session_manager.get_messages(session_id) - }) - - -async def on_agent_output(session_id: str, message: Dict[str, Any]): - """Handle agent output""" - msg_type = message.get('type', 'text') - content = message.get('content', '') - role = message.get('role', 'assistant') - - if msg_type == 'stream': - # Streaming update - await connection_manager.send_to_session( - session_id, { - 'type': 'stream', - 'content': content, - 'done': message.get('done', False) - }) - if message.get('done'): - session_manager.add_message(session_id, role, content, 'text') - else: - session_manager.add_message(session_id, role, content, msg_type, - message.get('metadata')) - await connection_manager.send_to_session( - session_id, { - 'type': 'message', - 'role': role, - 'content': content, - 'message_type': msg_type, - 'metadata': message.get('metadata') - }) - - -async def on_agent_log(session_id: str, log: Dict[str, Any]): - """Handle agent log""" - await connection_manager.send_to_session(session_id, { - 'type': 'log', - **log - }) - await connection_manager.broadcast_log({'session_id': session_id, **log}) - - -async def on_agent_progress(session_id: str, progress: Dict[str, Any]): - """Handle progress update""" - progress_type = progress.get('type', 'workflow') - - if progress_type == 'workflow': - session_manager.set_workflow_progress(session_id, progress) - session_manager.set_current_step(session_id, - progress.get('current_step')) - elif progress_type == 'file': - session_manager.set_file_progress(session_id, progress) - - await connection_manager.send_to_session(session_id, { - 'type': 'progress', - **progress - }) - - -async def on_agent_complete(session_id: str, result: Dict[str, Any]): - """Handle agent completion""" - session_manager.update_session(session_id, {'status': 'completed'}) - - if session_id in agent_runners: - del agent_runners[session_id] - if session_id in agent_tasks: - agent_tasks[session_id].cancel() - del agent_tasks[session_id] - - await connection_manager.send_to_session(session_id, { - 'type': 'complete', - 'result': result - }) - - -async def on_agent_error(session_id: str, error: Dict[str, Any]): - """Handle agent error""" - session_manager.update_session(session_id, {'status': 'error'}) - session_manager.add_message(session_id, 'system', - error.get('message', 'Unknown error'), 'error') - - if session_id in agent_runners: - del agent_runners[session_id] - if session_id in agent_tasks: - agent_tasks[session_id].cancel() - del agent_tasks[session_id] - - await connection_manager.send_to_session(session_id, { - 'type': 'error', - **error - }) diff --git a/webui/frontend/app/app.css b/webui/frontend/app/app.css new file mode 100644 index 000000000..d4990a89a --- /dev/null +++ b/webui/frontend/app/app.css @@ -0,0 +1,242 @@ +/* Tailwind v4. Skip the preflight layer so antd's own resets win. + * Layer order: utilities goes last, so Tailwind utilities beat antd's + * @layer antd output without needing `!important` on every class. + * antd's runtime cssinjs output is wrapped into `@layer antd` by + * entry.server.tsx (SSR) and the StyleProvider config in root.tsx (client). */ +@layer theme, base, antd, components, utilities; + +/* x-markdown typography themes (light/dark class applied by ). + * Imported here (not in the component) so the css always goes through Vite's + * pipeline — a deep package css import would crash Node SSR. Plus KaTeX's + * stylesheet for the Latex math plugin. */ +@import '@ant-design/x-markdown/themes/light.css'; +@import '@ant-design/x-markdown/themes/dark.css'; + +/* Class-based dark mode: any descendant of an element with class "dark" + * (typically ) gets dark: variants. */ +@custom-variant dark (&:where(.dark, .dark *)); + +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +/* ================================================================ + * MSA Design System → Tailwind v4 @theme token registration + * Usage examples: + * bg-msa-bg-1 text-msa-text-1 border-msa-line-1 + * rounded-msa-8 shadow-msa-s text-msa-sm + * ================================================================ */ +@theme { + /* --- Background --- */ + --color-msa-bg-1: var(--msa-bg-1); + --color-msa-bg-2: var(--msa-bg-2); + + /* --- Brand: Purple --- */ + --color-msa-purple-0: var(--msa-purple-0); + --color-msa-purple-1: var(--msa-purple-1); + --color-msa-purple-2: var(--msa-purple-2); + --color-msa-purple-3: var(--msa-purple-3); + --color-msa-purple-4: var(--msa-purple-4); + --color-msa-purple-5: var(--msa-purple-5); + --color-msa-purple-6: var(--msa-purple-6); + --color-msa-purple-7: var(--msa-purple-7); + --color-msa-purple-8: var(--msa-purple-8); + --color-msa-purple-9: var(--msa-purple-9); + --color-msa-purple-10: var(--msa-purple-10); + + /* --- Brand: Blue --- */ + --color-msa-blue-0: var(--msa-blue-0); + --color-msa-blue-1: var(--msa-blue-1); + --color-msa-blue-2: var(--msa-blue-2); + --color-msa-blue-3: var(--msa-blue-3); + --color-msa-blue-4: var(--msa-blue-4); + --color-msa-blue-5: var(--msa-blue-5); + --color-msa-blue-6: var(--msa-blue-6); + --color-msa-blue-7: var(--msa-blue-7); + --color-msa-blue-8: var(--msa-blue-8); + --color-msa-blue-9: var(--msa-blue-9); + --color-msa-blue-10: var(--msa-blue-10); + + /* --- Brand: Green --- */ + --color-msa-green-0: var(--msa-green-0); + --color-msa-green-1: var(--msa-green-1); + --color-msa-green-2: var(--msa-green-2); + --color-msa-green-3: var(--msa-green-3); + --color-msa-green-4: var(--msa-green-4); + --color-msa-green-5: var(--msa-green-5); + --color-msa-green-6: var(--msa-green-6); + --color-msa-green-7: var(--msa-green-7); + --color-msa-green-8: var(--msa-green-8); + --color-msa-green-9: var(--msa-green-9); + --color-msa-green-10: var(--msa-green-10); + + /* --- Neutral --- */ + --color-msa-neutral-0: var(--msa-neutral-0); + --color-msa-neutral-1: var(--msa-neutral-1); + --color-msa-neutral-2: var(--msa-neutral-2); + --color-msa-neutral-3: var(--msa-neutral-3); + --color-msa-neutral-4: var(--msa-neutral-4); + --color-msa-neutral-5: var(--msa-neutral-5); + --color-msa-neutral-6: var(--msa-neutral-6); + --color-msa-neutral-7: var(--msa-neutral-7); + --color-msa-neutral-8: var(--msa-neutral-8); + --color-msa-neutral-9: var(--msa-neutral-9); + --color-msa-neutral-10: var(--msa-neutral-10); + + /* --- Decoration --- */ + --color-msa-deco-purple: var(--msa-deco-purple); + --color-msa-deco-green: var(--msa-deco-green); + --color-msa-deco-blue: var(--msa-deco-blue); + --color-msa-deco-pink: var(--msa-deco-pink); + --color-msa-deco-yellow: var(--msa-deco-yellow); + --color-msa-deco-orange: var(--msa-deco-orange); + --color-msa-deco-orange1: var(--msa-deco-orange1); + --color-msa-deco-green2: var(--msa-deco-green2); + --color-msa-deco-pink2: var(--msa-deco-pink2); + --color-msa-deco-pink3: var(--msa-deco-pink3); + --color-msa-deco-red: var(--msa-deco-red); + --color-msa-deco-gray: var(--msa-deco-gray); + + /* --- Text --- */ + --color-msa-text-0: var(--msa-text-0); + --color-msa-text-1: var(--msa-text-1); + --color-msa-text-2: var(--msa-text-2); + --color-msa-text-3: var(--msa-text-3); + --color-msa-text-brand1: var(--msa-text-brand1); + --color-msa-text-brand2: var(--msa-text-brand2); + --color-msa-text-danger: var(--msa-text-danger); + --color-msa-text-disabled: var(--msa-text-disabled); + + /* --- Icon (currentColor glyphs) --- */ + --color-msa-icon-neutral: var(--msa-icon-neutral); + + /* --- Fill --- */ + --color-msa-fill-0: var(--msa-fill-0); + --color-msa-fill-1: var(--msa-fill-1); + --color-msa-fill-2: var(--msa-fill-2); + --color-msa-fill-3: var(--msa-fill-3); + --color-msa-fill-4: var(--msa-fill-4); + --color-msa-fill-5: var(--msa-fill-5); + --color-msa-fill-6: var(--msa-fill-6); + --color-msa-fill-trans: var(--msa-fill-trans); + --color-msa-fill-purple: var(--msa-fill-purple); + --color-msa-fill-green: var(--msa-fill-green); + --color-msa-fill-orange: var(--msa-fill-orange); + --color-msa-fill-cyan: var(--msa-fill-cyan); + --color-msa-fill-gray: var(--msa-fill-gray); + --color-msa-fill-blue: var(--msa-fill-blue); + --color-msa-fill-tag: var(--msa-fill-tag); + --color-msa-fill-code-box: var(--msa-fill-code-box); + --color-msa-fill-input: var(--msa-fill-input); + --color-msa-fill-error: var(--msa-fill-error); + --color-msa-fill-warning: var(--msa-fill-warning); + --color-msa-fill-brand: var(--msa-fill-brand); + + /* --- Line / Border --- */ + --color-msa-line-0: var(--msa-line-0); + --color-msa-line-1: var(--msa-line-1); + --color-msa-line-2: var(--msa-line-2); + --color-msa-line-3: var(--msa-line-3); + --color-msa-line-input: var(--msa-line-input); + + /* --- Border Radius --- */ + --radius-msa-0: 0px; + --radius-msa-2: 2px; + --radius-msa-4: 4px; + --radius-msa-6: 6px; + --radius-msa-8: 8px; + --radius-msa-12: 12px; + --radius-msa-16: 16px; + --radius-msa-24: 24px; + --radius-msa-full: 9999px; + + /* --- Shadows --- */ + --shadow-msa-light: 2px 2px 10px 2px rgba(63, 63, 63, 0.04); + --shadow-msa-s: 0px 1px 6px 0px rgba(38, 36, 76, 0.12); + --shadow-msa-m: 0px 2px 32px 0px rgba(39, 37, 76, 0.08); + --shadow-msa-l: 0px 2px 32px 0px rgba(39, 37, 76, 0.08); + --shadow-msa-popup: 0px 3px 6px -4px rgba(0, 0, 0, 0.12), 0px 6px 16px 0px rgba(0, 0, 0, 0.08), 0px 9px 28px 8px rgba(0, 0, 0, 0.05); + --shadow-msa-modal: 0px 6px 16px -8px rgba(0, 0, 0, 0.08), 0px 9px 28px 0px rgba(0, 0, 0, 0.05), 0px 12px 48px 16px rgba(0, 0, 0, 0.03); + --shadow-msa-btn-blue: 0px 6px 8px 0px rgba(97, 92, 237, 0.12); + --shadow-msa-btn-blue-hover: 0px 6px 8px 0px rgba(97, 92, 237, 0.2); + --shadow-msa-btn-red: 0px 6px 8px 0px rgba(235, 47, 47, 0.12); + --shadow-msa-btn-red-hover: 0px 6px 8px 0px rgba(235, 47, 47, 0.2); + + /* --- Typography --- */ + --font-family-msa: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', helvetica, arial, sans-serif; + --font-family-msa-mono: 'Monaco', 'Menlo', 'Consolas', monospace; + + --font-size-msa-xs: 12px; + --font-size-msa-sm: 13px; + --font-size-msa-base: 14px; + --font-size-msa-lg: 16px; + --font-size-msa-xl: 18px; + --font-size-msa-2xl: 20px; + --font-size-msa-3xl: 24px; + --font-size-msa-4xl: 32px; +} + +/* ================================================================ + * Global Resets + * ================================================================ */ +/* Tell the engine which palette the page is in, so BROWSER-DRAWN surfaces we + can't style follow the theme: scrollbars, form controls, ` + + } + onClick={() => fileInputRef.current?.click()} + disabled={isMaxFiles} + /> + + + )} + + {loading ? ( + + } + onClick={() => onCancel?.()} + /> + ) : ( + // A disabled button fires no pointer events, so the + // wrapper is what the tooltip hovers on — without it + // the "pick a model" hint would be unreachable. + { + if (modelMissing) setModelHintOpen(true) + }} + > + } + onClick={() => handleSubmit(draft)} + disabled={!canSend} + /> + + )} + + + + } + /> + + + + + + + {hasProjectPicker && ( + setCreateOpen(false)} + onCreated={(p) => { + setCreateOpen(false) + setProjects((prev) => [...prev, p]) + setPickedProjectId(p.id) + onProjectChange?.(p.id) + }} + /> + )} + + ) +} diff --git a/webui/frontend/app/components/common/DeferredSkeleton.tsx b/webui/frontend/app/components/common/DeferredSkeleton.tsx new file mode 100644 index 000000000..4e22dbd4b --- /dev/null +++ b/webui/frontend/app/components/common/DeferredSkeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from 'antd' + +/** + * DeferredSkeleton — the single entry point for loading skeletons. + * + * Every skeleton renders inside the anti-flicker gate (`.msa-loading-defer`, + * app.css): invisible for the first 250ms — a fast load never flashes a + * skeleton at all — then a 150ms fade-in for genuinely slow loads. Pure CSS, + * so it is SSR-safe (no hydration timing). + * + * Two shapes: + * - default: a standard antd paragraph skeleton (`rows`); + * - `children`: a custom skeleton structure (card grids, table mocks, …) that + * only needs the gate, not the default paragraph. + */ +export function DeferredSkeleton({ + rows = 6, + className = '', + children +}: { + /** Paragraph rows for the default antd skeleton (ignored with children). */ + rows?: number + /** Extra classes on the gate wrapper (layout/padding of the placeholder). */ + className?: string + children?: React.ReactNode +}) { + return ( +
+ {children ?? } +
+ ) +} diff --git a/webui/frontend/app/components/common/EmptyState.tsx b/webui/frontend/app/components/common/EmptyState.tsx new file mode 100644 index 000000000..8e3d174ec --- /dev/null +++ b/webui/frontend/app/components/common/EmptyState.tsx @@ -0,0 +1,81 @@ +import type { ReactNode } from 'react' +import emptyLight from '~/assets/images/empty-light.png' +import emptyDark from '~/assets/images/empty-dark.png' +import chatEmptyLight from '~/assets/images/chat-empty-light.png' +import chatEmptyDark from '~/assets/images/chat-empty-dark.png' +import { useTheme } from '~/lib/theme' + +export type EmptyStateSize = 'sm' | 'md' | 'lg' + +/** Which illustration to show. `box` is the generic "nothing here"; `chat` is for + * conversation lists, where a speech bubble reads better than a crate. */ +export type EmptyStateArt = 'box' | 'chat' + +const ART: Record = { + box: { light: emptyLight, dark: emptyDark }, + chat: { light: chatEmptyLight, dark: chatEmptyDark } +} + +const IMG_SIZE: Record = { + sm: 'h-[160px]', + md: 'h-[200px]', + lg: 'h-[240px]' +} + +const PADDING: Record = { + sm: 'py-6', + md: 'py-10', + lg: 'py-16' +} + +/** The description tracks the size variant: at `sm` (a sidebar group, a popover) + * the body text sits next to 12px UI copy, where `text-sm` reads oversized. */ +const TEXT_SIZE: Record = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-sm' +} + +interface Props { + /** Image & spacing size variant */ + size?: EmptyStateSize + /** Illustration variant (defaults to the generic empty box) */ + art?: EmptyStateArt + /** Description text below the empty icon */ + description?: string + /** Optional action button rendered below the description */ + action?: ReactNode + /** Custom className for outer container */ + className?: string +} + +/** + * EmptyState — Unified empty state component. + * + * Shows a fixed empty-box illustration, an optional description, + * and an optional action button (passed in as ReactNode). + */ +export function EmptyState({ + size = 'md', + art = 'box', + description, + action, + className = '' +}: Props) { + const { theme } = useTheme() + const src = ART[art][theme === 'dark' ? 'dark' : 'light'] + + return ( +
+ + {description && ( +

+ {description} +

+ )} + {action &&
{action}
} +
+ ) +} diff --git a/webui/frontend/app/components/common/ErrorState.tsx b/webui/frontend/app/components/common/ErrorState.tsx new file mode 100644 index 000000000..1773371cf --- /dev/null +++ b/webui/frontend/app/components/common/ErrorState.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from 'react' +import errorLight from '~/assets/images/error-light.png' +import errorDark from '~/assets/images/error-dark.png' +import { useTheme } from '~/lib/theme' + +interface Props { + /** Big headline — the HTTP status code, or the error's own name when a + * client-side exception has no status. Omitted when neither exists. */ + code?: string + /** What failed, as reported by the server. */ + description: string + /** Primary action, usually "back home". */ + action?: ReactNode +} + +/** + * ErrorState — full-page error layout: illustration on the left, code / + * description / action stacked on the right. + * + * Stacks vertically below `sm` so the illustration never squeezes the text off + * screen on a phone. Sized against the viewport rather than the parent: `` + * carries no height, so a `h-full` chain collapses here and would pin the block + * to the top of the page. + */ +export function ErrorState({ code, description, action }: Props) { + const { theme } = useTheme() + + return ( +
+
+ +
+ {code && ( +
+ {code} +
+ )} +

+ {description} +

+ {action &&
{action}
} +
+
+
+ ) +} diff --git a/webui/frontend/app/components/common/FileCard.tsx b/webui/frontend/app/components/common/FileCard.tsx new file mode 100644 index 000000000..79474e25b --- /dev/null +++ b/webui/frontend/app/components/common/FileCard.tsx @@ -0,0 +1,428 @@ +import { Image, Tooltip } from 'antd' +import type React from 'react' +import { useT } from '~/lib/i18n' + +// File type icons. Inlined (`?react`) instead of loaded as URLs so the +// theme-adaptive badges (e.g. web) can follow `currentColor` — an external SVG +// referenced by has no inherited color and would render black. +import iconDefault from '~/assets/files/default.svg?react' +import iconPdf from '~/assets/files/pdf.svg?react' +import iconWord from '~/assets/files/word.svg?react' +import iconExcel from '~/assets/files/excel.svg?react' +import iconPpt from '~/assets/files/ppt.svg?react' +import iconZip from '~/assets/files/zip.svg?react' +import iconMarkdown from '~/assets/files/md.svg?react' +import iconJava from '~/assets/files/java.svg?react' +import iconJavascript from '~/assets/files/js.svg?react' +import iconPython from '~/assets/files/py.svg?react' +import iconText from '~/assets/files/txt.svg?react' +import iconMp3 from '~/assets/files/mp3.svg?react' +import iconWeb from '~/assets/files/web.svg?react' +import iconImage from '~/assets/icons/image.svg?react' +import iconAudio from '~/assets/icons/audio.svg?react' +import iconVideo from '~/assets/icons/video.svg?react' +import CloseIcon from '~/assets/icons/close.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +/** Upload lifecycle of an attached file. Selection triggers an immediate + * upload to the project workspace; the composer blocks send until every file + * is 'done' and drops 'error' ones. */ +export type UploadStatus = 'uploading' | 'done' | 'error' + +export interface AttachedFile { + id: string + file: File + name: string + byte: number + type: 'file' | 'image' | 'audio' | 'video' + src?: string + /** Upload lifecycle; undefined is treated as 'done' (already-persisted). */ + status?: UploadStatus + /** Workspace-relative path returned by the upload (e.g. user_files/foo.png). */ + path?: string + /** Raw byte URL for preview / agent reference. */ + url?: string +} + +export function fileToAttached(file: File): AttachedFile { + const isImage = file.type.startsWith('image/') + const isAudio = file.type.startsWith('audio/') + const isVideo = file.type.startsWith('video/') + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + file, + name: file.name, + byte: file.size, + type: isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file', + src: isImage || isAudio || isVideo ? URL.createObjectURL(file) : undefined, + status: 'uploading' + } +} + +// ---- Utils ---- + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(2)}MB` +} + +function getFileExt(name: string): string { + const parts = name.split('.') + return parts.length > 1 ? parts.pop()!.toUpperCase() : 'FILE' +} + +type FileIcon = React.FC> + +// File extension to icon mapping +const fileIcons: Record = { + // Documents + PDF: iconPdf, + DOC: iconWord, + DOCX: iconWord, + // Spreadsheets + XLS: iconExcel, + XLSX: iconExcel, + CSV: iconExcel, + // Presentations + PPT: iconPpt, + PPTX: iconPpt, + // Archives + ZIP: iconZip, + RAR: iconZip, + '7Z': iconZip, + TAR: iconZip, + GZ: iconZip, + // Code / Text + MD: iconMarkdown, + TXT: iconText, + LOG: iconText, + // Web sources + HTML: iconWeb, + HTM: iconWeb, + CSS: iconWeb, + // .ipynb has no dedicated badge asset; use the generic file badge so doc + // cards render uniformly (matching the composer upload card) instead of the + // odd line-art glyph. + IPYNB: iconDefault, + JS: iconJavascript, + TS: iconJavascript, + JSX: iconJavascript, + TSX: iconJavascript, + JAVA: iconJava, + PY: iconPython, + // Media + PNG: iconImage, + JPG: iconImage, + JPEG: iconImage, + GIF: iconImage, + SVG: iconImage, + WEBP: iconImage, + MP3: iconMp3, + WAV: iconAudio, + OGG: iconAudio, + FLAC: iconAudio, + MP4: iconVideo, + MOV: iconVideo, + AVI: iconVideo, + WEBM: iconVideo, + MKV: iconVideo +} + +function getFileIcon(ext: string): FileIcon { + return fileIcons[ext] ?? iconDefault +} + +/** File-type badge for a filename (extension-based, with fallback). The color + * class only affects badges drawn with `currentColor` (the neutral ones); the + * brand-colored plates carry their own fills. */ +export function FileTypeIcon({ + name, + className = '' +}: { + name: string + className?: string +}) { + const Icon = getFileIcon(getFileExt(name)) + return +} + +/** Format a byte count into a compact human string (e.g. 1.25MB). */ +export function formatFileSize(bytes: number): string { + return formatSize(bytes) +} + +// ---- Remove Button ---- + +function RemoveButton({ onClick }: { onClick?: () => void }) { + return ( + + ) +} + +// Card chrome for media (image/audio/video) shown in the message list, so they +// match the document card. It's only applied when the card is NOT removable: +// composer upload previews (removable) render bare, without this outer frame. +// `group-hover/filecard:*` reacts to the clickable wrapper in a chat bubble +// (UserBubble) and tints the card on hover. +const MEDIA_CARD = + 'rounded-xl border border-msa-line-2 bg-msa-fill-0 transition-colors group-hover/filecard:bg-msa-fill-4' + +// The native control (audio/video) and the antd image preview own their own +// clicks: stop the event so it doesn't bubble to the bubble wrapper's +// open-in-workspace handler. Clicking the surrounding card padding still opens. +const stopControl = { + onClick: (e: React.MouseEvent) => e.stopPropagation(), + onKeyDown: (e: React.KeyboardEvent) => e.stopPropagation() +} + +// ---- Image Card ---- + +function ImageCard({ + src, + name, + removable, + onRemove +}: { + src?: string + name: string + removable?: boolean + onRemove?: () => void +}) { + const thumb = ( +
+ {name} +
+ ) + return ( +
+ {removable && } + {removable ? ( + thumb + ) : ( +
{thumb}
+ )} +
+ ) +} + +// ---- Audio Card ---- + +function AudioCard({ + src, + removable, + onRemove +}: { + src?: string + removable?: boolean + onRemove?: () => void +}) { + return ( +
+ {removable && } +
+
+
+ ) +} + +// ---- Video Card ---- + +function VideoCard({ + src, + removable, + onRemove +}: { + src?: string + removable?: boolean + onRemove?: () => void +}) { + return ( +
+ {removable && } +
+
+
+ ) +} + +// ---- Document File Card ---- + +function DocCard({ + name, + byte, + note, + removable, + onRemove +}: { + name: string + byte?: number + /** Replaces the ext/size line with a note (e.g. "this file was deleted"). */ + note?: string + removable?: boolean + onRemove?: () => void +}) { + const ext = getFileExt(name) + + return ( +
+ {removable && } +
+ +
+ {name} + {note ? ( + {note} + ) : ( + + {ext} + {byte != null && ` ${formatSize(byte)}`} + + )} +
+
+
+ ) +} + +// ---- Main FileCard ---- + +interface FileCardProps { + name: string + byte?: number + type?: 'file' | 'image' | 'audio' | 'video' + src?: string + removable?: boolean + onRemove?: () => void + /** Upload lifecycle; when 'uploading'/'error' a status overlay is shown. */ + status?: UploadStatus + /** Retry handler; wired to the error overlay so a failed upload can re-run. */ + onRetry?: () => void + /** History replay: the workspace file is gone. Forces the generic doc card + * (no media preview) and shows `note` in place of the ext/size line. */ + deleted?: boolean + /** Sub-label under the name (e.g. the "file deleted" note). */ + note?: string +} + +/** Overlay covering a card while an upload is in flight or after it failed. */ +function StatusOverlay({ + status, + onRetry +}: { + status: UploadStatus + onRetry?: () => void +}) { + const { t } = useT() + if (status === 'uploading') { + return ( +
+ +
+ ) + } + return ( + + + + ) +} + +export function FileCard({ + name, + byte, + type = 'file', + src, + removable = false, + onRemove, + status, + onRetry, + deleted = false, + note +}: FileCardProps) { + const card = (() => { + // A deleted file has no bytes to preview — always fall back to the generic + // doc card, carrying the note (e.g. "this file was deleted"). + if (deleted) { + return ( + + ) + } + switch (type) { + case 'image': + return ( + + ) + case 'audio': + return + case 'video': + return + default: + return ( + + ) + } + })() + + if (!status || status === 'done') return card + return ( +
+ {card} + +
+ ) +} diff --git a/webui/frontend/app/components/common/FolderTree.css b/webui/frontend/app/components/common/FolderTree.css new file mode 100644 index 000000000..d95ab8cde --- /dev/null +++ b/webui/frontend/app/components/common/FolderTree.css @@ -0,0 +1,32 @@ +.folder-tree .ant-tree-treenode { + align-items: center; +} + +.folder-tree .ant-tree-node-content-wrapper { + display: flex !important; + align-items: center; + min-width: 0; + padding: 0; + overflow: hidden; +} + +.folder-tree .ant-tree-iconEle { + display: inline-flex !important; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; +} + +.folder-tree .ant-tree-title { + flex: 1 1 auto; + min-width: 0; + margin-left: 0; +} + +/* showIcon is off (icon rendered inside the title); drop the empty icon slot + so it doesn't reserve blank width before the in-title icon. */ +.folder-tree .ant-tree-iconEle:empty { + display: none !important; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/FolderTree.tsx b/webui/frontend/app/components/common/FolderTree.tsx new file mode 100644 index 000000000..4d5a2ffca --- /dev/null +++ b/webui/frontend/app/components/common/FolderTree.tsx @@ -0,0 +1,694 @@ +import { ConfigProvider, Dropdown, Tree } from 'antd' +import type { MenuProps, TreeDataNode, TreeProps } from 'antd' +import { + type FC, + type ReactNode, + type SVGProps, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { collectDroppedFiles } from '~/lib/dropFiles' +import type { DroppedFile } from '~/lib/dropFiles' +import { useT } from '~/lib/i18n' +import './FolderTree.css' + +// File type icons, inlined (`?react`) so the neutral ones can follow +// `currentColor` (see FileCard). +import iconDefault from '~/assets/files/default.svg?react' +import iconPdf from '~/assets/files/pdf.svg?react' +import iconWord from '~/assets/files/word.svg?react' +import iconExcel from '~/assets/files/excel.svg?react' +import iconPpt from '~/assets/files/ppt.svg?react' +import iconZip from '~/assets/files/zip.svg?react' +import iconMarkdown from '~/assets/files/md.svg?react' +import iconJava from '~/assets/files/java.svg?react' +import iconJavascript from '~/assets/files/js.svg?react' +import iconPython from '~/assets/files/py.svg?react' +import iconText from '~/assets/files/txt.svg?react' +import iconMp3 from '~/assets/files/mp3.svg?react' +import iconWeb from '~/assets/files/web.svg?react' +import iconFolder from '~/assets/icons/folder.svg?react' + +type FileIcon = FC> + +// Extension → icon mapping +const FILE_ICONS: Record = { + pdf: iconPdf, + doc: iconWord, + docx: iconWord, + xls: iconExcel, + xlsx: iconExcel, + csv: iconExcel, + ppt: iconPpt, + pptx: iconPpt, + zip: iconZip, + rar: iconZip, + '7z': iconZip, + tar: iconZip, + gz: iconZip, + md: iconMarkdown, + js: iconJavascript, + ts: iconJavascript, + jsx: iconJavascript, + tsx: iconJavascript, + java: iconJava, + py: iconPython, + json: iconJavascript, + mp3: iconMp3, + html: iconWeb, + htm: iconWeb, + css: iconWeb, + log: iconText, + txt: iconText, + yaml: iconDefault, + yml: iconDefault, + bin: iconDefault, + sh: iconDefault, + xml: iconDefault, + svg: iconDefault +} + +function iconFor(title: string, isDir: boolean): ReactNode { + const ext = title.split('.').pop()?.toLowerCase() ?? '' + const Icon = isDir ? iconFolder : FILE_ICONS[ext] ?? iconDefault + return +} + +// Node keys are `file:` / `dir:` (built by the caller). +function parseKey(key: string): { isDir: boolean; path: string } { + const isDir = key.startsWith('dir:') + return { isDir, path: key.slice(key.indexOf(':') + 1) } +} +const baseName = (p: string) => p.split('/').pop() ?? p +const parentDir = (p: string) => { + const i = p.lastIndexOf('/') + return i === -1 ? '' : p.slice(0, i) +} + +/** File-management actions surfaced by the right-click menu and drag & drop. + * The tree only reports intent (paths); the host performs the API calls, name + * prompts and confirmations. */ +export interface FolderTreeActions { + onNewFile: (dir: string) => void + onNewFolder: (dir: string) => void + /** Commit an inline rename: give `path` the new base name `newName`. */ + onRename: (path: string, newName: string) => void + onDelete: (path: string, isDir: boolean) => void + onCopyPath: (path: string) => void + onDownload: (path: string) => void + /** Move/rename `src` to `dest` (both workspace-relative). */ + onMove: (src: string, dest: string) => void + /** Native OS files dropped onto a folder node (`dir` '' = workspace root). */ + onUploadTo: (dir: string, files: DroppedFile[]) => void + /** Batch delete a multi-selection. */ + onDeleteMany: (items: { path: string; isDir: boolean }[]) => void + /** Batch download (files only; folders are filtered out by the caller). */ + onDownloadMany: (paths: string[]) => void + /** Copy several workspace paths (newline-joined) to the clipboard. */ + onCopyPaths: (paths: string[]) => void + /** Batch move a multi-selection into a folder. */ + onMoveMany: (moves: { src: string; dest: string }[]) => void +} + +interface FolderTreeProps { + /** Tree structure data (string titles, `file:`/`dir:` keys). */ + treeData: TreeDataNode[] + /** Currently selected file key */ + selectedKey: string + /** Callback when a leaf node is selected */ + onSelect: (key: string) => void + /** Case-insensitive filter: non-matching files are hidden, matches highlighted. */ + filter?: string + /** File-management callbacks; when omitted the tree is read-only. */ + actions?: FolderTreeActions + /** Container className */ + className?: string + /** Expand every folder when the tree (re)loads. Default FALSE (repo-wide + * convention): the tree starts collapsed and only the ancestors of + * `selectedKey` auto-expand — deep-link style, revealing exactly the path + * being opened. Pass true to restore expand-all-on-load. */ + defaultExpandAll?: boolean +} + +// Collect keys of directory nodes (those with children), for expand-all. +function dirKeys(nodes: TreeDataNode[], acc: string[] = []): string[] { + for (const n of nodes) { + if (n.children) { + acc.push(String(n.key)) + dirKeys(n.children, acc) + } + } + return acc +} + +// A pruned copy of the tree keeping only files whose name matches `filter` +// (case-insensitive) and the directories on the way to them. Returns the kept +// nodes plus the dir keys that must be expanded to reveal the matches. +function filterTree( + nodes: TreeDataNode[], + q: string, + expand: string[] +): TreeDataNode[] { + const out: TreeDataNode[] = [] + for (const n of nodes) { + const title = String(n.title ?? '') + if (n.children) { + const kids = filterTree(n.children, q, expand) + const selfMatch = title.toLowerCase().includes(q) + if (kids.length > 0 || selfMatch) { + expand.push(String(n.key)) + out.push({ ...n, children: kids }) + } + } else if (title.toLowerCase().includes(q)) { + out.push(n) + } + } + return out +} + +function Highlight({ text, q }: { text: string; q: string }) { + if (!q) return <>{text} + const idx = text.toLowerCase().indexOf(q.toLowerCase()) + if (idx === -1) return <>{text} + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + q.length)} + + {text.slice(idx + q.length)} + + ) +} + +// Inline rename editor rendered in place of a node's name. Autofocuses and +// pre-selects the base name (excluding the extension). Enter/blur commits, +// Escape cancels; a `done` guard prevents Escape's blur from also committing. +function RenameInput({ + initial, + onCommit, + onCancel +}: { + initial: string + onCommit: (value: string) => void + onCancel: () => void +}) { + const [value, setValue] = useState(initial) + const ref = useRef(null) + const done = useRef(false) + useEffect(() => { + const el = ref.current + if (!el) return + el.focus() + const dot = initial.lastIndexOf('.') + if (dot > 0) el.setSelectionRange(0, dot) + else el.select() + }, [initial]) + const commit = () => { + if (done.current) return + done.current = true + onCommit(value) + } + const cancel = () => { + if (done.current) return + done.current = true + onCancel() + } + return ( + setValue(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + commit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + onBlur={commit} + className="mr-2 min-w-0 flex-1 rounded border border-msa-line-2 bg-msa-bg-1 px-1 text-sm text-msa-text-1 outline-none" + /> + ) +} + +/** + * A file-tree browser: full-row select/hover, a right-click context menu + * (new file/folder, rename, delete, copy path, download), drag-to-move between + * folders, native OS drag-and-drop upload onto folders, and a live name filter. + */ +export function FolderTree({ + treeData, + selectedKey, + onSelect, + filter = '', + actions, + className, + defaultExpandAll = false +}: FolderTreeProps) { + const { t } = useT() + const [expandedKeys, setExpandedKeys] = useState([]) + const [autoExpandParent, setAutoExpandParent] = useState(true) + // Folder key currently under a native file drag, for drop highlighting. + const [dropDir, setDropDir] = useState(null) + // Multi-selection (Ctrl/Cmd/Shift-click). The externally opened file + // (`selectedKey`) seeds it; plain single clicks open a file, modified clicks + // just grow the selection for batch operations. + const [selectedKeys, setSelectedKeys] = useState([]) + // After a context-menu item is clicked, antd closes the overlay and the + // click can “fall through” to the tree row underneath and select it. Ignore + // any select fired within a short window after a menu interaction. + const suppressSelectUntil = useRef(0) + // Anchor for Shift range selection (the last plain/toggle-clicked node). + const anchorKey = useRef(null) + // Key of the node being renamed inline (its name shows an ). + const [renamingKey, setRenamingKey] = useState(null) + + // Seed / reset the selection from the externally opened file. A plain click + // opens a file (updating `selectedKey`) and collapses the selection to it; + // modified clicks don't change `selectedKey`, so the multi-selection sticks. + useEffect(() => { + setSelectedKeys(selectedKey ? [selectedKey] : []) + anchorKey.current = selectedKey || null + }, [selectedKey]) + + const q = filter.trim().toLowerCase() + const allDirKeys = useMemo(() => dirKeys(treeData), [treeData]) + const dirSig = allDirKeys.join('|') + + const { data, matchExpand } = useMemo(() => { + if (!q) return { data: treeData, matchExpand: null as string[] | null } + const expand: string[] = [] + return { data: filterTree(treeData, q, expand), matchExpand: expand } + }, [treeData, q]) + + // Expand policy on (re)load: everything (default), or — when + // `defaultExpandAll` is off — only the ancestors of the selected file, so a + // deep link reveals exactly its own path. While filtering, expand only the + // ancestors of the matches so results are revealed. + useEffect(() => { + if (q && matchExpand) { + setExpandedKeys(matchExpand) + setAutoExpandParent(true) + } else if (defaultExpandAll) { + setExpandedKeys(allDirKeys) + setAutoExpandParent(false) + } else { + setExpandedKeys([]) + setAutoExpandParent(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q, dirSig, defaultExpandAll]) + + // Collapsed-by-default mode: reveal the path of the externally opened file + // (merge its ancestor dirs into the expansion, keeping user-opened folders). + useEffect(() => { + if (defaultExpandAll || !selectedKey.startsWith('file:')) return + const path = selectedKey.slice('file:'.length) + const parts = path.split('/').slice(0, -1) + if (parts.length === 0) return + const ancestors: string[] = [] + for (let i = 1; i <= parts.length; i++) { + ancestors.push(`dir:${parts.slice(0, i).join('/')}`) + } + setExpandedKeys((prev) => [...new Set([...prev, ...ancestors])]) + setAutoExpandParent(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedKey, defaultExpandAll, dirSig]) + + const menuItems = (isDir: boolean, path: string, key: string): MenuProps['items'] => { + if (!actions) return [] + // When right-clicking a node that's part of a multi-selection, offer batch + // operations over the whole selection instead of single-node actions. + if (selectedKeys.length > 1 && selectedKeys.includes(key)) { + const picked = selectedKeys.map(parseKey) + const files = picked.filter((p) => !p.isDir).map((p) => p.path) + const n = picked.length + const items: MenuProps['items'] = [] + if (files.length > 0) { + items.push({ + key: 'downloadMany', + label: `${t.workspace.download} (${files.length})`, + onClick: () => actions.onDownloadMany(files) + }) + } + items.push({ + key: 'copyMany', + label: `${t.workspace.copyPath} (${n})`, + onClick: () => actions.onCopyPaths(picked.map((p) => p.path)) + }) + items.push({ type: 'divider' }) + items.push({ + key: 'deleteMany', + label: `${t.workspace.delete} (${n})`, + danger: true, + onClick: () => actions.onDeleteMany(picked) + }) + return items + } + const items: MenuProps['items'] = [] + if (isDir) { + items.push({ + key: 'newFile', + label: t.workspace.newFile, + onClick: () => actions.onNewFile(path) + }) + items.push({ + key: 'newFolder', + label: t.workspace.newFolder, + onClick: () => actions.onNewFolder(path) + }) + items.push({ type: 'divider' }) + } else { + items.push({ + key: 'download', + label: t.workspace.download, + onClick: () => actions.onDownload(path) + }) + } + items.push({ + key: 'rename', + label: t.workspace.rename, + onClick: () => setRenamingKey(key) + }) + items.push({ + key: 'copy', + label: t.workspace.copyPath, + onClick: () => actions.onCopyPath(path) + }) + items.push({ type: 'divider' }) + items.push({ + key: 'delete', + label: t.workspace.delete, + danger: true, + onClick: () => actions.onDelete(path, isDir) + }) + return items + } + + // Native OS file drag handlers, attached per title. Gated on `types` carrying + // 'Files' so they never interfere with antd's internal node dragging. + const fileDragProps = (dir: string, key: string) => + actions + ? { + onDragOver: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + if (dropDir !== key) setDropDir(key) + }, + onDragLeave: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + setDropDir((k) => (k === key ? null : k)) + }, + onDrop: async (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.stopPropagation() + setDropDir(null) + // Entry-tree walk, not `dataTransfer.files`: a dropped FOLDER is not a + // file there (it surfaces as an unreadable directory entry), so + // dropping one used to upload a single junk file named after it. + const entries = await collectDroppedFiles(e.dataTransfer) + if (entries.length > 0) actions.onUploadTo(dir, entries) + } + } + : {} + + const styledData = useMemo(() => { + const decorate = (nodes: TreeDataNode[]): TreeDataNode[] => + nodes.map((node) => { + const key = String(node.key) + const { isDir, path } = parseKey(key) + const title = String(node.title ?? '') + const uploadDir = isDir ? path : parentDir(path) + const renaming = renamingKey === key + // The icon lives INSIDE the title (not antd's `showIcon` slot) and the + // title fills the row, so the context-menu trigger and native file-drop + // target cover the whole row — not just the file name text. + const titleEl = renaming ? ( + + {iconFor(title, isDir)} + { + setRenamingKey(null) + const next = v.trim() + if (next && next !== title) actions?.onRename(path, next) + }} + onCancel={() => setRenamingKey(null)} + /> + + ) : ( + { + if (!selectedKeys.includes(key)) setSelectedKeys([key]) + }} + {...fileDragProps(uploadDir, key)} + > + {iconFor(title, isDir)} + + + + + ) + const highlighted = renaming + ? '' // no selection highlight while editing the name inline + : selectedKeys.includes(key) + ? 'bg-msa-fill-4' + : dropDir === key + ? 'bg-msa-fill-4 ring-1 ring-inset ring-msa-line-2' + : 'hover:bg-msa-fill-4' + return { + ...node, + // While renaming, drop the context-menu wrapper so right-click and + // drag don't interfere with the input. + title: + actions && !renaming ? ( + { + domEvent?.stopPropagation?.() + suppressSelectUntil.current = Date.now() + 400 + } + }} + trigger={['contextMenu']} + > + {titleEl} + + ) : ( + titleEl + ), + className: `rounded-lg ${highlighted}`, + children: node.children ? decorate(node.children) : undefined + } + }) + return decorate(data) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, selectedKeys, dropDir, q, actions, renamingKey]) + + // Flat list of currently visible node keys in display order (a folder's + // children only count when it's expanded), so Shift-range selection matches + // exactly the rows the user sees. + const visibleKeys = useMemo(() => { + const expanded = new Set(expandedKeys) + const out: string[] = [] + const walk = (nodes: TreeDataNode[]) => { + for (const n of nodes) { + const k = String(n.key) + out.push(k) + if (n.children && expanded.has(k)) walk(n.children) + } + } + walk(data) + return out + }, [data, expandedKeys]) + + const rangeBetween = (a: string, b: string): string[] => { + const ai = visibleKeys.indexOf(a) + const bi = visibleKeys.indexOf(b) + if (ai === -1 || bi === -1) return [b] + const [lo, hi] = ai <= bi ? [ai, bi] : [bi, ai] + return visibleKeys.slice(lo, hi + 1) + } + + // Editor-style selection: plain click selects one (and opens a file); + // Ctrl/Cmd/Alt toggles a single row; Shift selects the contiguous range from + // the anchor (the last plain/toggle click) to the clicked row. + const handleTreeSelect: TreeProps['onSelect'] = (_keys, info) => { + if (Date.now() < suppressSelectUntil.current) { + suppressSelectUntil.current = 0 + return + } + const ne = info.nativeEvent as MouseEvent | undefined + const clicked = String(info.node.key) + const shift = !!ne && ne.shiftKey + const toggle = !!ne && (ne.ctrlKey || ne.metaKey || ne.altKey) + if (shift && anchorKey.current) { + // Range from anchor to clicked; anchor stays put for further shift-clicks. + setSelectedKeys(rangeBetween(anchorKey.current, clicked)) + return + } + if (toggle) { + setSelectedKeys((prev) => + prev.includes(clicked) + ? prev.filter((k) => k !== clicked) + : [...prev, clicked] + ) + anchorKey.current = clicked + return + } + // Plain click: collapse to just this node; a file opens, a FOLDER toggles + // its expansion (the whole row acts as the caret — no need to hit the tiny + // arrow). + setSelectedKeys([clicked]) + anchorKey.current = clicked + if (info.node.isLeaf) { + onSelect(clicked) + return + } + setExpandedKeys((prev) => + prev.includes(clicked) + ? prev.filter((k) => k !== clicked) + : [...prev, clicked] + ) + // Manual toggling must not be undone by antd's ancestor auto-expansion. + setAutoExpandParent(false) + } + + // Replace the browser's default drag ghost (a loose snapshot of the whole + // tree row, with stray padding/whitespace) with a compact icon+name pill. + const onDragStart: TreeProps['onDragStart'] = (info) => { + const dt = info.event.dataTransfer + const row = (info.event.target as HTMLElement | null)?.closest?.( + '.ant-tree-treenode' + ) as HTMLElement | null + if (!dt || !dt.setDragImage || !row) return + const iconEl = row.querySelector('.ant-tree-title img') as HTMLImageElement | null + const name = row.querySelector('.ant-tree-title')?.textContent ?? '' + // Dragging any node of a multi-selection moves the whole set: show a count. + const dragKey = String(info.node.key) + const multi = selectedKeys.length > 1 && selectedKeys.includes(dragKey) + const ghost = document.createElement('div') + ghost.style.cssText = + 'position:fixed;top:-1000px;left:-1000px;display:inline-flex;align-items:center;gap:8px;max-width:260px;padding:4px 10px;border-radius:8px;background:var(--msa-bg-1);border:1px solid var(--msa-line-2);box-shadow:var(--msa-shadow-s);font-size:13px;line-height:20px;color:var(--msa-text-1);white-space:nowrap;overflow:hidden' + if (!multi && iconEl) { + const i = iconEl.cloneNode(true) as HTMLImageElement + i.style.cssText = 'width:14px;height:14px;flex:0 0 auto' + ghost.appendChild(i) + } + const label = document.createElement('span') + label.textContent = multi + ? `${selectedKeys.length} ${t.workspace.selectedItems}` + : name + label.style.cssText = 'overflow:hidden;text-overflow:ellipsis' + ghost.appendChild(label) + document.body.appendChild(ghost) + dt.setDragImage(ghost, 12, 16) + // Remove once the browser has snapshotted it for the drag image. + setTimeout(() => ghost.remove(), 0) + } + + // Internal drag-to-move: drop onto a folder moves into it; drop onto/next to + // a file targets that file's parent dir. Dragging a node of a multi-selection + // moves the whole set. Guards against no-ops and moving a folder into its own + // subtree. + const onDrop: TreeProps['onDrop'] = (info) => { + if (!actions) return + const target = parseKey(String(info.node.key)) + const destDir = + !info.dropToGap && target.isDir ? target.path : parentDir(target.path) + const dragKey = String(info.dragNode.key) + const sources = + selectedKeys.length > 1 && selectedKeys.includes(dragKey) + ? selectedKeys.map(parseKey) + : [parseKey(dragKey)] + const moves: { src: string; dest: string }[] = [] + for (const { path: src, isDir: srcIsDir } of sources) { + const dest = destDir ? `${destDir}/${baseName(src)}` : baseName(src) + if (dest === src) continue + if (srcIsDir && dest.startsWith(`${src}/`)) continue + moves.push({ src, dest }) + } + if (moves.length === 0) return + if (moves.length === 1) actions.onMove(moves[0].src, moves[0].dest) + else actions.onMoveMany(moves) + } + + return ( + + {q && styledData.length === 0 ? ( +
+ {t.workspace.noSearchResults} +
+ ) : ( +
{ + if (!actions || renamingKey || selectedKeys.length !== 1) return + if (e.key !== 'F2' && e.key !== 'Enter') return + const el = e.target as HTMLElement | null + if ( + el && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable) + ) + return + e.preventDefault() + e.stopPropagation() + setRenamingKey(selectedKeys[0]) + }} + > + { + setExpandedKeys(keys.map(String)) + setAutoExpandParent(false) + }} + onDrop={onDrop} + onDragStart={actions ? onDragStart : undefined} + className={className} + rootClassName="folder-tree" + classNames={{ + itemSwitcher: 'before:hidden' + }} + onSelect={handleTreeSelect} + /> +
+ )} +
+ ) +} diff --git a/webui/frontend/app/components/common/IconButton.tsx b/webui/frontend/app/components/common/IconButton.tsx new file mode 100644 index 000000000..db8c30dc7 --- /dev/null +++ b/webui/frontend/app/components/common/IconButton.tsx @@ -0,0 +1,71 @@ +import { forwardRef } from 'react' +import { MsaButton } from './MsaButton' +import type { MsaButtonProps } from './MsaButton' + +/* ================================================================ + * IconButton — Square icon-only button + * + * Based on MsaButton with preset square layout: + * - Centered icon + * - Configurable size (default 32px) + * - Rounded-xl border-radius + * + * Usage (icons come from app/assets/icons via `?react`, sized by class): + * } /> + * } variant="primary" size="sm" /> + * ================================================================ */ + +interface IconButtonProps extends Omit { + /** + * Predefined sizes: + * - `xs` 20px (sidebar actions) + * - `sm` 28px (compact) + * - `md` 32px (default) + * - `lg` 40px + */ + size?: 'xs' | 'sm' | 'md' | 'lg' + /** Stop click event from bubbling to parent elements. Default: true */ + stopPropagation?: boolean +} + +const sizeStyles: Record = { + xs: 'h-5 w-5 min-w-0 rounded-md text-xs', + sm: 'h-7 w-7 min-w-0 rounded-lg text-xs', + md: 'h-8 w-8 min-w-0 rounded-xl text-sm', + lg: 'h-10 w-10 min-w-0 rounded-xl text-base' +} + +export const IconButton = forwardRef( + ( + { + size = 'md', + variant = 'ghost', + stopPropagation = true, + className = '', + onClick, + ...rest + }, + ref + ) => { + const noHoverBg = variant === 'ghost' ? 'hover:bg-transparent' : '' + // Icon centering is handled by the base MsaButton; here we only add the + // square layout + size preset. Any caller `classNames` flows via `...rest`. + return ( + { + if (stopPropagation) { + e.stopPropagation() + e.preventDefault() + } + onClick?.(e) + }} + {...rest} + /> + ) + } +) + +IconButton.displayName = 'IconButton' diff --git a/webui/frontend/app/components/common/Markdown.css b/webui/frontend/app/components/common/Markdown.css new file mode 100644 index 000000000..33f767d47 --- /dev/null +++ b/webui/frontend/app/components/common/Markdown.css @@ -0,0 +1,27 @@ +.msa-md-body { + --light-bg: var(--msa-fill-1); + --dark-bg: var(--msa-fill-1); +} + +.msa-md-body .msa-code-highlighter pre code { + line-height: 1.6; + overflow-x: auto; +} + +.msa-md-body pre { + background: var(--light-bg) !important; +} + +/* ---- Inline code ---------------------------------------------------------- + * Subtle chip on the msa fill token instead of the theme's gray + heavy + * border; slightly smaller so it doesn't crowd the prose line. */ +.msa-md-body pre:not(.msa-code-highlighter pre) { + border-radius: 6px !important; +} + +.msa-md-body pre code:not(.msa-code-highlighter pre code) { + background: var(--msa-fill-2) !important; + color: var(--msa-text-2) !important; + padding: 2px 10px !important; + font-size: 0.9em !important; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/Markdown.tsx b/webui/frontend/app/components/common/Markdown.tsx new file mode 100644 index 000000000..07d8ae8e9 --- /dev/null +++ b/webui/frontend/app/components/common/Markdown.tsx @@ -0,0 +1,192 @@ +import { Actions, CodeHighlighter, Mermaid } from '@ant-design/x' +import { ConfigProvider } from 'antd' +import { useContext } from 'react' +import { XMarkdown } from '@ant-design/x-markdown' +import type { ComponentProps } from '@ant-design/x-markdown' +import Latex from '@ant-design/x-markdown/plugins/Latex' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { useTheme } from '~/lib/theme' +import CopyIcon from '~/assets/icons/copy.svg?react' +import './Markdown.css' +// Typography themes (x-markdown-light / x-markdown-dark) are @imported in +// app.css — importing the package css here would crash Node SSR (deep css +// imports of an externalized package bypass Vite's pipeline). + +interface Props { + content: string + /** Pass true while the content is still being streamed in. */ + streaming?: boolean + /** Handle a leading YAML frontmatter block (```---…---```). CommonMark has + * no frontmatter concept (and x-markdown ships no extension for it), so the + * raw block would render as a broken heading/paragraph mix. When enabled, + * the block is re-emitted as a fenced ```yaml code block instead. */ + frontmatter?: boolean +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ + +function withFrontmatterAsYaml(src: string): string { + const m = FRONTMATTER_RE.exec(src) + if (!m) return src + return '```yaml\n' + m[1] + '\n```\n\n' + src.slice(m[0].length) +} + +/** Flatten the ReactNode children of a mapped tag into plain text (fenced + * code bodies arrive as text nodes / arrays of text nodes). */ +function textOf(children: React.ReactNode): string { + if (typeof children === 'string') return children + if (Array.isArray(children)) return children.map(textOf).join('') + return children == null ? '' : String(children) +} + +/** CodeHighlighter hardcodes the prism `oneLight` palette and ignores antd's + * darkAlgorithm — in dark mode inject `oneDark` via `highlightProps` (kept + * transparent so the card's own background wins). */ +function useHighlightProps() { + const { theme } = useTheme() + if (theme !== 'dark') return undefined + return { + style: { + ...oneDark, + 'pre[class*="language-"]': { + ...oneDark['pre[class*="language-"]'], + background: 'transparent', + margin: 0 + }, + 'code[class*="language-"]': { + ...oneDark['code[class*="language-"]'], + background: 'transparent' + } + } + } +} + +/** Code-block header matching CodeHighlighter's built-in one (language name + * left, copy action right) with ONE change: the copy glyph is our own + * `copy.svg`, the same asset the assistant-bubble copy button uses. + * + * A custom `header` is the only way in — CodeHighlighter exposes no icon prop + * and hardcodes ``. `Actions.Copy` itself does take + * an `icon`, so the built-in copy behaviour (clipboard write + copied feedback) + * is kept as-is; only the glyph is swapped. + * + * The header/title class names have to be reproduced for the component's own + * stylesheet to still apply, so the prefix is resolved the same way the + * component resolves it — antd's `ConfigContext.getPrefixCls` (which is exactly + * what x's internal `useXProviderContext` forwards) — rather than hardcoding + * `ant-`, which a ConfigProvider `prefixCls` would break. */ +function CodeHeader({ lang, code }: { lang: string; code: string }) { + const { getPrefixCls } = useContext(ConfigProvider.ConfigContext) + const prefixCls = getPrefixCls('codeHighlighter') + return ( +
+ {lang} + } /> +
+ ) +} + +/** Fenced code blocks → CodeHighlighter (language pill + copy button); + * ```mermaid fences → live Mermaid diagrams; inline code stays plain. */ +function Code(props: ComponentProps) { + const highlightProps = useHighlightProps() + const className = String((props as { className?: string }).className ?? '') + const lang = /language-(\w+)/.exec(className)?.[1] + const body = textOf(props.children) + + if (!lang) { + // Inline code (no language- class): keep the default element. + return {props.children} + } + if (lang === 'mermaid') { + // Render the diagram only once the fence is complete; while streaming, + // show the source as a code block (avoids mermaid parse churn). + if (props.streamStatus === 'loading') { + return ( + } + > + {body} + + ) + } + return {body} + } + return ( + } + > + {body} + + ) +} + +/** `` tag emitted by x-markdown for mermaid fences → diagram. */ +function MermaidTag(props: ComponentProps) { + return {textOf(props.children)} +} + +/** Links always open in a NEW tab. Markdown here is model output (citations, + * search results, docs) — navigating the SPA away from a live conversation + * would drop the user out of the chat (and can abort an in-flight turn), so + * every link leaves the app in a separate tab instead of in place. + * `rel="noopener noreferrer"` because the target is untrusted content. */ +function Anchor(props: ComponentProps) { + const { + children, + // Dropped: x-markdown injects parser metadata that is not valid DOM. + domNode: _domNode, + streamStatus: _streamStatus, + lang: _lang, + block: _block, + ...rest + } = props as ComponentProps & { href?: string } + return ( + + {children} + + ) +} + +// Stable references (x-markdown best practice: never rebuild per render). +const COMPONENTS = { + a: Anchor, + code: Code, + mermaid: MermaidTag +} +const CONFIG = { extensions: Latex() } + +/** + * Project-wide Markdown renderer. Wraps `@ant-design/x-markdown` so chat + * messages, skill viewers, and any other consumers share a single import path + * — making future swaps (theme tokens, plugins, custom components) one-edit + * changes. + * + * Bundled capabilities (chat-oriented): + * - GFM basics (tables, lists, links…) from x-markdown itself; + * - fenced code → CodeHighlighter, ```mermaid → Mermaid diagrams; + * - LaTeX math ($…$ / $$…$$) via the official Latex plugin (KaTeX); + * - optional YAML frontmatter handling (`frontmatter` prop); + * - light/dark typography theme following the app theme. + */ +export function Markdown({ content, streaming, frontmatter }: Props) { + const { theme } = useTheme() + return ( + + ) +} diff --git a/webui/frontend/app/components/common/McpSelector.tsx b/webui/frontend/app/components/common/McpSelector.tsx new file mode 100644 index 000000000..de83212e0 --- /dev/null +++ b/webui/frontend/app/components/common/McpSelector.tsx @@ -0,0 +1,96 @@ +import { Popover } from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { useT } from '~/lib/i18n' +import type { Mcp, Project } from '~/lib/types' +import { PillButton } from './PillButton' +import McpSelectIcon from '~/assets/icons/mcp-select.svg?react' + +interface McpSelectorProps { + items: Mcp[] + project?: Project | null +} + +/** + * Read-only view of the MCP services active for this chat. Enablement lives + * ONLY in project settings and global settings — the composer has no per-chat + * toggles; it lists what those settings resolved to and links to the right + * settings page (project tab when a project is selected, else global). + */ +export function McpSelector({ items, project }: McpSelectorProps) { + const { t } = useT() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + // Only MCPs enabled in global/project settings apply to the chat. + const enabledItems = useMemo(() => items.filter((m) => m.enabled), [items]) + + const settingsPath = project + ? `/projects/${project.id}?tab=mcps` + : '/settings/mcp-skills?tab=mcps' + + const content = ( +
+ {/* Header: title only — enablement is settings-driven */} +
+ + {t.home.mcpPopoverTitle} + +
+ +
+ + {/* List */} +
+ {enabledItems.length ? ( + enabledItems.map((it) => ( +
+ + {it.name} + +
+ )) + ) : ( +
+ )} +
+ +
+ + {/* Footer: settings link */} +
+ { + setOpen(false) + navigate(settingsPath) + }} + > + {t.home.mcpSettings} + +
+
+ ) + + return ( + + + } + > + {enabledItems.length} {t.home.mcpPill} + + + ) +} diff --git a/webui/frontend/app/components/common/ModelSelector.css b/webui/frontend/app/components/common/ModelSelector.css new file mode 100644 index 000000000..9145ee884 --- /dev/null +++ b/webui/frontend/app/components/common/ModelSelector.css @@ -0,0 +1,11 @@ +/* ModelSelector.css + * When a model item is hovered or selected, hide the divider directly above and + * below it so the highlight reads as a standalone rounded card. The dividers + * keep their space (opacity transition), so the layout never jumps. + */ +.msel-list button:hover+.msel-divider, +.msel-list .msel-selected+.msel-divider, +.msel-list .msel-divider:has(+ button:hover), +.msel-list .msel-divider:has(+ .msel-selected) { + opacity: 0; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/ModelSelector.tsx b/webui/frontend/app/components/common/ModelSelector.tsx new file mode 100644 index 000000000..3051c485c --- /dev/null +++ b/webui/frontend/app/components/common/ModelSelector.tsx @@ -0,0 +1,166 @@ +import { CheckOutlined } from '@ant-design/icons' +import { Popover } from 'antd' +import { Fragment, useMemo, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentSettings, Model, Provider } from '~/lib/types' +import { PillButton } from './PillButton' +import { EmptyState } from './EmptyState' +import { DeferredSkeleton } from './DeferredSkeleton' +import './ModelSelector.css' +import JumpIcon from '~/assets/icons/jump.svg?react' + +interface ModelSelectorProps { + /** null while the lists are still loading — the panel then shows a + * skeleton instead of an "empty" state that isn't true yet. */ + models: Model[] | null + providers: Provider[] | null + settings: AgentSettings | null + onSelectModel: (providerId: string, modelId: string) => void +} + +export function ModelSelector({ + models, + providers, + settings, + onSelectModel +}: ModelSelectorProps) { + const { t } = useT() + const [open, setOpen] = useState(false) + const [activeProviderId, setActiveProviderId] = useState(null) + + const defaultModel = useMemo( + () => models?.find((m) => m.id === settings?.default_model_id) ?? null, + [models, settings] + ) + + // Sync active provider with the current default provider when opening. + const effectiveProviderId = + activeProviderId ?? + defaultModel?.provider_id ?? + settings?.default_provider_id ?? + providers?.[0]?.id ?? + null + + const activeProvider = useMemo( + () => providers?.find((p) => p.id === effectiveProviderId) ?? null, + [providers, effectiveProviderId] + ) + + const providerModels = useMemo( + () => + (models ?? []) + .filter((m) => m.provider_id === effectiveProviderId) + .sort((a, b) => + (a.display_name || a.name).localeCompare(b.display_name || b.name) + ), + [models, effectiveProviderId] + ) + + const handleSelectModel = (model: Model) => { + onSelectModel(model.provider_id, model.id) + setOpen(false) + } + + return ( + + {/* Left: providers */} +
+ {(providers ?? []).map((p) => { + const selected = p.id === effectiveProviderId + return ( + + ) + })} +
+ + {/* Right: models */} +
+ {models === null || providers === null ? ( + + ) : activeProvider ? ( + providerModels.length === 0 ? ( +
+ +
+ ) : ( +
+ {providerModels.map((m, idx) => { + const selected = m.id === settings?.default_model_id + return ( + + {idx > 0 && ( +
+ )} + + + ) + })} +
+ ) + ) : ( +
+ — +
+ )} +
+
+ } + > + + } + > + {defaultModel?.display_name ?? t.home.modelUnset} + +
+ ) +} diff --git a/webui/frontend/app/components/common/MsaButton.tsx b/webui/frontend/app/components/common/MsaButton.tsx new file mode 100644 index 000000000..5d92b6385 --- /dev/null +++ b/webui/frontend/app/components/common/MsaButton.tsx @@ -0,0 +1,64 @@ +import { Button } from 'antd' +import type { ButtonProps } from 'antd' +import { forwardRef } from 'react' + +/* ================================================================ + * MsaButton — Base button + * + * Wraps antd Button with: + * 1. Removed default border / shadow + * 2. Five color variants (primary / filled / tonal / outlined / ghost) + * 3. antd click ripple effect + * + * Size, radius, spacing are all controlled via external className. + * ================================================================ */ + +export interface MsaButtonProps extends Omit { + /** + * Color variant: + * - `primary` Deep purple background (purple-10) + white text + * - `filled` White/surface background (text-0) + dark text, hover fill-3 + * - `tonal` Gray fill (fill-2) + dark text (default) + * - `outlined` Transparent background + border + dark text + * - `ghost` Transparent background + secondary text color + */ + variant?: 'primary' | 'filled' | 'tonal' | 'outlined' | 'ghost' +} + +const variantStyles: Record = { + primary: + 'bg-msa-purple-10 text-white disabled:cursor-not-allowed disabled:opacity-40', + filled: + 'bg-msa-fill-0 text-msa-text-1 hover:bg-msa-fill-3 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-msa-text-0', + tonal: + 'bg-msa-fill-2 text-msa-text-1 hover:bg-msa-fill-3 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-msa-fill-2', + outlined: + 'bg-msa-fill-0 !border !border-solid !border-msa-line-1 text-msa-text-1 hover:bg-msa-fill-2 disabled:cursor-not-allowed disabled:text-msa-text-disabled', + ghost: + 'bg-transparent text-msa-text-2 hover:bg-msa-fill-2 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-transparent' +} + +export const MsaButton = forwardRef( + ({ variant = 'tonal', className = '', classNames, ...rest }, ref) => { + const extraClassNames = + classNames && typeof classNames === 'object' + ? (classNames as Record) + : {} + const extraIcon = + typeof extraClassNames.icon === 'string' ? extraClassNames.icon : '' + return ( + + ) +} diff --git a/webui/frontend/app/components/common/MsaTextArea.tsx b/webui/frontend/app/components/common/MsaTextArea.tsx new file mode 100644 index 000000000..42f7f4b88 --- /dev/null +++ b/webui/frontend/app/components/common/MsaTextArea.tsx @@ -0,0 +1,74 @@ +import { Input } from 'antd' +import type { TextAreaProps } from 'antd/es/input' +import type { CSSProperties } from 'react' +import { useEffect, useState } from 'react' + +const LINE_HEIGHT = 22 +const PADDING_VERTICAL = 10 // paddingTop 4 + paddingBottom 4 + borderTop 1 + borderBottom 1 + +interface MsaTextAreaClassNames { + root?: string + textarea?: string + clear?: string + count?: string +} + +interface MsaTextAreaStyles { + root?: CSSProperties + textarea?: CSSProperties + clear?: CSSProperties + count?: CSSProperties +} + +interface MsaTextAreaProps extends Omit< + TextAreaProps, + 'classNames' | 'styles' +> { + classNames?: MsaTextAreaClassNames + styles?: MsaTextAreaStyles +} + +/** + * Wrapper around antd Input.TextArea that prevents SSR hydration height flash. + * + * During SSR (before client mount), autoSize is disabled and a fixed height is + * calculated from minRows (rows × lineHeight + padding). After mount, autoSize + * takes over normally. + */ +export function MsaTextArea({ + autoSize, + classNames: classNamesProp, + styles: stylesProp, + ...rest +}: MsaTextAreaProps) { + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + }, []) + + // Compute fixed height for SSR phase: rows * lineHeight + padding + const minRows = + typeof autoSize === 'object' ? (autoSize.minRows ?? 2) : undefined + const ssrHeight = minRows + ? minRows * LINE_HEIGHT + PADDING_VERTICAL + : undefined + + return ( + + ) +} diff --git a/webui/frontend/app/components/common/NProgressHandler.css b/webui/frontend/app/components/common/NProgressHandler.css new file mode 100644 index 000000000..afa8c4d0e --- /dev/null +++ b/webui/frontend/app/components/common/NProgressHandler.css @@ -0,0 +1,32 @@ +/* NProgress top loading bar — themed with MSA design tokens. + * We deliberately do NOT import nprogress/nprogress.css so the default blue + * (#29d) never applies; all visual styling is defined here via CSS variables. + * NProgress injects a #nprogress container with .bar > .peg into . */ + +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + position: fixed; + top: 0; + left: 0; + z-index: 3000; + width: 100%; + height: 2px; + background: var(--msa-purple-4); +} + +/* The little glowing comet at the leading edge of the bar. */ +#nprogress .peg { + display: block; + position: absolute; + right: 0; + width: 100px; + height: 100%; + opacity: 1; + transform: rotate(3deg) translate(0, -4px); + box-shadow: + 0 0 10px var(--msa-purple-4), + 0 0 5px var(--msa-purple-4); +} diff --git a/webui/frontend/app/components/common/NProgressHandler.tsx b/webui/frontend/app/components/common/NProgressHandler.tsx new file mode 100644 index 000000000..75dcbd622 --- /dev/null +++ b/webui/frontend/app/components/common/NProgressHandler.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { useNavigation } from 'react-router' +import nprogress from 'nprogress' + +import './NProgressHandler.css' + +nprogress.configure({ showSpinner: false, trickleSpeed: 120 }) + +/** + * Top loading bar driven by React Router navigation. While a client page + * transition is pending (`navigation.location` is set) NProgress runs; it + * completes when the new route commits. Styling lives in the co-located + * NProgressHandler.css (design-token colors, no default NProgress blue). + * Initial full-document loads aren't navigations, so the bar only appears on + * in-app page switches. Start is DELAYED 150ms so a fast transition never + * flashes the bar (nprogress.done() is a no-op when it never started). + */ +export function NProgressHandler() { + const navigation = useNavigation() + const isNavigating = Boolean(navigation.location) + + useEffect(() => { + if (!isNavigating) return + const t = setTimeout(() => nprogress.start(), 150) + return () => { + clearTimeout(t) + nprogress.done() + } + }, [isNavigating]) + + return null +} diff --git a/webui/frontend/app/components/common/PillButton.tsx b/webui/frontend/app/components/common/PillButton.tsx new file mode 100644 index 000000000..8f0491d89 --- /dev/null +++ b/webui/frontend/app/components/common/PillButton.tsx @@ -0,0 +1,89 @@ +import { Tooltip } from 'antd' +import { forwardRef, useEffect, useRef, useState } from 'react' +import { MsaButton } from './MsaButton' +import type { MsaButtonProps } from './MsaButton' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/* ================================================================ + * PillButton — Pill-shaped selector button (e.g. Model / MCP pills) + * + * rounded-full + icon + optional dropdown caret + * + * The label is width-capped and truncates with an ellipsis so a long + * name (e.g. a full model id) stays compact on narrow layouts instead + * of blowing out the composer row. The cap is container-relative (cqw, + * resolved against the composer's @container) rather than viewport- + * relative, so it still holds when the composer column is narrow but the + * viewport is wide (e.g. a detail rail is open). A tooltip surfaces the + * full text — but only when the label is actually clipped. + * ================================================================ */ + +interface PillButtonProps extends Omit { + /** Whether to show dropdown arrow (default true) */ + caret?: boolean + /** Panel open state — flips the caret (same 180° + transition as the + * accordion headers) so the pill reads as expanded. */ + open?: boolean +} + +export const PillButton = forwardRef( + ( + { + caret = true, + open = false, + children, + className = '', + classNames, + ...rest + }, + ref + ) => { + const labelRef = useRef(null) + const [clipped, setClipped] = useState(false) + const [labelText, setLabelText] = useState('') + useEffect(() => { + const el = labelRef.current + if (!el) return + const measure = () => { + setClipped(el.scrollWidth > el.clientWidth + 1) + setLabelText(el.textContent ?? '') + } + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, [children]) + + const extra = + classNames && typeof classNames === 'object' + ? (classNames as Record) + : {} + return ( + + {/* Tooltip only engages when the label is clipped (empty title = no + tooltip); it wraps the inner span, not the button, so it never + conflicts with the selector Popover that triggers on the button. */} + + + {children} + + + {caret && ( + + )} + + ) + } +) + +PillButton.displayName = 'PillButton' diff --git a/webui/frontend/app/components/common/RailDrawer.tsx b/webui/frontend/app/components/common/RailDrawer.tsx new file mode 100644 index 000000000..94befdcc3 --- /dev/null +++ b/webui/frontend/app/components/common/RailDrawer.tsx @@ -0,0 +1,56 @@ +import { Drawer } from 'antd' +import type { ReactNode } from 'react' + +interface Props { + open: boolean + onClose: () => void + /** Rail content — the workspace rail or a step-detail rail. */ + children: ReactNode + /** + * Drawer width. Defaults to the chat view's overlay width; a page with more + * room to spare (the project detail workspace tab) can widen it. + */ + size?: string + /** Extra class on the drawer root, e.g. `xl:hidden` to bind it to a breakpoint. */ + rootClassName?: string + /** antd passthrough — used to clear rail content only on a genuine close. */ + afterOpenChange?: (open: boolean) => void + /** Drop the content when closed (a standalone drawer should not keep a stale file). */ + destroyOnHidden?: boolean +} + +/** + * Overlay presentation of a right rail. + * + * The rail components draw their own header (title, refresh, close) and fill + * their container, so the antd chrome is switched off and the body padding + * zeroed. That combination is easy to get wrong — a stacked second title, a + * second close button, a panel collapsed to its content width — so it lives + * here once and both callers (chat view below `xl`, project detail workspace + * tab) share it. + */ +export function RailDrawer({ + open, + onClose, + children, + size = 'min(720px, 92vw)', + rootClassName, + afterOpenChange, + destroyOnHidden +}: Props) { + return ( + + {children} + + ) +} diff --git a/webui/frontend/app/components/common/SkillSelector.tsx b/webui/frontend/app/components/common/SkillSelector.tsx new file mode 100644 index 000000000..7b1dae1d1 --- /dev/null +++ b/webui/frontend/app/components/common/SkillSelector.tsx @@ -0,0 +1,95 @@ +import { Popover } from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { useT } from '~/lib/i18n' +import type { Project, Skill } from '~/lib/types' +import { PillButton } from './PillButton' + +interface SkillSelectorProps { + items: Skill[] + project?: Project | null +} + +/** + * Read-only view of the skills active for this chat. Enablement lives ONLY in + * project settings and global settings — the composer has no per-chat toggles; + * it lists what those settings resolved to and links to the right settings + * page (project tab when a project is selected, else global). + */ +export function SkillSelector({ items, project }: SkillSelectorProps) { + const { t } = useT() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + // Only skills enabled in global/project settings apply to the chat. + const enabledItems = useMemo(() => items.filter((s) => s.enabled), [items]) + + const settingsPath = project + ? `/projects/${project.id}?tab=skills` + : '/settings/mcp-skills?tab=skills' + + const content = ( +
+ {/* Header: title only — enablement is settings-driven */} +
+ + {t.home.skillPopoverTitle} + +
+ +
+ + {/* List */} +
+ {enabledItems.length ? ( + enabledItems.map((it) => ( +
+ + {it.name} + +
+ )) + ) : ( +
+ )} +
+ +
+ + {/* Footer: settings link */} +
+ { + setOpen(false) + navigate(settingsPath) + }} + > + {t.home.skillSettings} + +
+
+ ) + + return ( + + + } + > + {enabledItems.length} {t.home.skillPill} + + + ) +} diff --git a/webui/frontend/app/components/common/StableSender.tsx b/webui/frontend/app/components/common/StableSender.tsx new file mode 100644 index 000000000..487a5b9b8 --- /dev/null +++ b/webui/frontend/app/components/common/StableSender.tsx @@ -0,0 +1,52 @@ +import { Sender } from '@ant-design/x' +import type { SenderProps } from '@ant-design/x/es/sender' +import { useMemo } from 'react' + +const LINE_HEIGHT = 22 +const PADDING_BLOCK = 5 + +/** Imperative handle exposed by Sender (focus/insert/getValue…). */ +export type SenderHandle = React.ComponentRef + +type StableSenderProps = SenderProps & { + /** Imperative Sender handle (insert/focus/getValue) — needed in slot mode + * where `value` is uncontrolled. React 19: ref is a regular prop. */ + ref?: React.Ref> +} + +export function StableSender(props: StableSenderProps) { + const { autoSize, classNames, styles, ref, ...rest } = props + + const minRows = typeof autoSize === 'object' ? (autoSize.minRows ?? 1) : 1 + + const mergedClassNames = useMemo( + () => ({ + ...classNames, + input: `resize-none ${classNames?.input ?? ''}` + }), + [classNames] + ) + + const mergedStyles = useMemo( + () => ({ + ...styles, + input: { + minHeight: LINE_HEIGHT * minRows + PADDING_BLOCK * 2, + ...styles?.input + } + }), + [styles, minRows] + ) + + return ( + + ) +} + +StableSender.Header = Sender.Header diff --git a/webui/frontend/app/components/layout/Sidebar.tsx b/webui/frontend/app/components/layout/Sidebar.tsx new file mode 100644 index 000000000..8576af087 --- /dev/null +++ b/webui/frontend/app/components/layout/Sidebar.tsx @@ -0,0 +1,826 @@ +import { App, Dropdown, Input, Modal, Popover, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { IconButton } from '~/components/common/IconButton' +import { EmptyState } from '~/components/common/EmptyState' +import { useEffect, useMemo, useState } from 'react' +import logoImg from '~/assets/images/logo.png' +import { + NavLink, + useLocation, + useNavigate, + useRevalidator, + useRouteLoaderData +} from 'react-router' +import { MsaButton } from '~/components/common/MsaButton' +import { NewProjectModal } from '~/components/project/NewProjectModal' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import { usePresence } from '~/lib/presenceContext' +import { useUrlPath } from '~/lib/useUrlPath' +import type { Project, Session } from '~/lib/types' +import SidebarToggleIcon from '~/assets/icons/sidebar-toggle.svg?react' +import McpIcon from '~/assets/icons/mcp.svg?react' +import SkillIcon from '~/assets/icons/skill.svg?react' +import SettingsIcon from '~/assets/icons/settings.svg?react' +import NewChatIcon from '~/assets/icons/new-chat.svg?react' +import MoreChatsIcon from '~/assets/icons/more-chats.svg?react' +import AddIcon from '~/assets/icons/add.svg?react' +import NewProjectIcon from '~/assets/icons/new-project.svg?react' +import MoreIcon from '~/assets/icons/more.svg?react' +import ExpandIcon from '~/assets/icons/expand.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +interface AppLoaderData { + projects: Project[] + sessions: Session[] +} + +const DEFAULT_PROJECT_ID = 'default' + +interface SidebarProps { + collapsed?: boolean + onCollapse?: () => void + onExpand?: () => void + onNavigate?: () => void +} + +export function Sidebar({ + collapsed = false, + onCollapse, + onExpand, + onNavigate +}: SidebarProps) { + const { t } = useT() + const navigate = useNavigate() + const revalidator = useRevalidator() + const data = useRouteLoaderData('layouts/app') as AppLoaderData | undefined + const projects = data?.projects ?? [] + const sessions = data?.sessions ?? [] + + // Project modal state + const [projectModalOpen, setProjectModalOpen] = useState(false) + const [editingProject, setEditingProject] = useState(null) + + const openCreateProject = () => { + setEditingProject(null) + setProjectModalOpen(true) + } + + const openEditProject = (p: Project) => { + setEditingProject(p) + setProjectModalOpen(true) + } + + const sessionsByProject = useMemo(() => { + const map = new Map() + for (const s of sessions) { + const pid = s.project_id ?? DEFAULT_PROJECT_ID + const list = map.get(pid) + if (list) list.push(s) + else map.set(pid, [s]) + } + return map + }, [sessions]) + + // Rendered in the order the API returns (plain creation order). The default + // project is no longer pinned to the top — it behaves like any other project. + const orderedProjects = projects + + const openNewChat = () => { + navigate('/') + onNavigate?.() + } + + return ( + <> + + + {/* Project create/edit modal */} + setProjectModalOpen(false)} + onCreated={(p) => { + setProjectModalOpen(false) + revalidator.revalidate() + navigate(`/projects/${p.id}`) + }} + onUpdated={() => { + setProjectModalOpen(false) + revalidator.revalidate() + }} + /> + + ) +} + +function SidebarNavItem({ + to, + label, + icon, + onNavigate, + className +}: { + to: string + label: string + icon: React.ReactNode + onNavigate?: () => void + className?: string +}) { + return ( + + {icon} + {label} + + ) +} + +/** The project row's two actions (new chat + rename/delete menu), shared by the + * expanded sidebar row and the collapsed sidebar's popover row so both offer the + * same thing. `pinned` keeps them visible instead of hover-revealed — used on the + * row whose project page is open, which is already highlighted. */ +function ProjectRowActions({ + project, + pinned, + onNavigate, + onEditProject +}: { + project: Project + pinned: boolean + onNavigate?: () => void + onEditProject?: (p: Project) => void +}) { + const { t } = useT() + const { modal } = App.useApp() + const navigate = useNavigate() + const location = useLocation() + const revalidator = useRevalidator() + + const handleDeleteProject = () => { + modal.confirm({ + title: t.sidebar.deleteProject, + content: t.sidebar.confirmDeleteProject, + okText: t.sidebar.confirmOk, + cancelText: t.sidebar.confirmCancel, + okButtonProps: { danger: true }, + onOk: async () => { + await api.deleteProject(project.id) + revalidator.revalidate() + if (location.pathname.startsWith(`/projects/${project.id}`)) + navigate('/') + } + }) + } + + const projectMenu: MenuProps = { + items: [ + { + key: 'edit', + label: t.sidebar.editProject, + onClick: () => { + onEditProject?.(project) + } + }, + ...(!project.is_default + ? [ + { + key: 'delete', + label: t.sidebar.deleteProject, + danger: true, + onClick: handleDeleteProject + } + ] + : []) + ] + } + + const actionClass = `!shrink-0 !transition-opacity hover:!text-msa-purple-5 ${ + pinned ? '' : '!opacity-0 group-hover:!opacity-100' + }` + + return ( + <> + + } + variant="ghost" + size="xs" + className={actionClass} + onClick={() => { + navigate(`/projects/${project.id}/new`) + onNavigate?.() + }} + /> + + {/* Extra wrapper needed because Dropdown close events bypass the trigger + button (and here also keeps the click off the row's toggle). */} + e.stopPropagation()}> + + + } + variant="ghost" + size="xs" + className={actionClass} + /> + + + + + ) +} + +function CollapsedProjectList({ + projects, + sessionsByProject, + onNavigate, + onEditProject +}: { + projects: Project[] + sessionsByProject: Map + onNavigate?: () => void + onEditProject?: (p: Project) => void +}) { + const content = ( +
+ {projects.map((p) => ( + + ))} +
+ ) + + return ( +
+ + } + stopPropagation={false} + /> + +
+ ) +} + +/** Collapsed sidebar popover: single project group with expand/collapse */ +function CollapsedProjectGroup({ + project, + sessions, + onNavigate, + onEditProject +}: { + project: Project + sessions: Session[] + onNavigate?: () => void + onEditProject?: (p: Project) => void +}) { + const location = useLocation() + const navigate = useNavigate() + const isActiveProject = location.pathname.startsWith( + `/projects/${project.id}` + ) + const isProjectPage = + location.pathname.replace(/\/+$/, '') === `/projects/${project.id}` + const [open, setOpen] = useState(isActiveProject) + + const projectName = project.name + + return ( +
+ {/* Project header — mirrors the expanded row: the row toggles the group, + the NAME enters the project. Without that click the collapsed sidebar + had no way into a project's own page at all. */} +
setOpen(!open)} + > + + + + { + e.stopPropagation() + navigate(`/projects/${project.id}`) + onNavigate?.() + }} + > + {projectName} + + {sessions.length > 0 && ( + + {sessions.length} + + )} + +
+ {/* Sessions — the SAME row component the expanded sidebar uses, so the + rename/delete menu, running spinner and active highlight behave + identically here. This popover previously hand-rolled the row and its + "more" glyph was a bare with no Dropdown and no handler, i.e. a + decoration that looked clickable but did nothing. */} + {open && sessions.length > 0 && ( +
+ {sessions.map((s) => ( + + ))} +
+ )} +
+ ) +} + +function RecentEmpty() { + const { t } = useT() + return ( +
+ +

{t.nav.recentEmpty}

+
+ ) +} + +function ProjectGroup({ + project, + sessions, + onNavigate, + onEditProject +}: { + project: Project + sessions: Session[] + onNavigate?: () => void + onEditProject?: (p: Project) => void +}) { + const { t } = useT() + const location = useLocation() + const navigate = useNavigate() + + const isActiveProject = location.pathname.startsWith( + `/projects/${project.id}` + ) + const [open, setOpen] = useState(isActiveProject) + + useEffect(() => { + if (isActiveProject) setOpen(true) + }, [isActiveProject]) + + // All sessions are shown directly (no secondary fold / "show all" toggle). + const visibleSessions = sessions + + const projectName = project.name + // EXACTLY this project's own detail page — not one of its sessions, not its + // "new chat" route. Only this pins the row's highlight and its two actions; + // `isActiveProject` (any descendant route) still colors the name and keeps the + // group expanded, which is the "you are inside this project" cue. + const isProjectPage = + location.pathname.replace(/\/+$/, '') === `/projects/${project.id}` + + return ( +
+ {/* Project header row */} +
setOpen(!open)} + > + {/* Chevron */} + { + e.stopPropagation() + setOpen(!open) + }} + > + + + {/* Project name — click to enter project detail */} + { + e.stopPropagation() + navigate(`/projects/${project.id}`) + onNavigate?.() + }} + > + {projectName} + + + {sessions.length} + + +
+ + {/* Session list */} + {open && ( +
+ {sessions.length === 0 ? ( + // The shared empty state, not a bare "empty" label. `chat` art: this + // list holds conversations, so the speech bubble fits where the + // generic crate does not. + + ) : ( + <> + {visibleSessions.map((s) => ( + + ))} + + )} +
+ )} +
+ ) +} + +function SessionItem({ + session, + projectId, + onNavigate +}: { + session: Session + projectId: string + onNavigate?: () => void +}) { + const { t } = useT() + const { modal } = App.useApp() + const navigate = useNavigate() + const location = useLocation() + const revalidator = useRevalidator() + const { running } = usePresence() + const isRunning = running.has(session.id) || !!session.running + const [renameOpen, setRenameOpen] = useState(false) + const [renameValue, setRenameValue] = useState('') + + const handleRename = async () => { + const title = renameValue.trim() + if (!title || title === session.title) { + setRenameOpen(false) + return + } + await api.updateSession(session.id, { title }) + revalidator.revalidate() + setRenameOpen(false) + } + + const handleDeleteSession = () => { + modal.confirm({ + title: t.sidebar.deleteSession, + content: t.sidebar.confirmDeleteSession, + okText: t.sidebar.confirmOk, + cancelText: t.sidebar.confirmCancel, + okButtonProps: { danger: true }, + onOk: async () => { + await api.deleteSession(session.id) + revalidator.revalidate() + const isActive = location.pathname.includes(`/sessions/${session.id}`) + if (isActive) navigate(`/projects/${projectId}`) + } + }) + } + + const sessionMenu: MenuProps = { + items: [ + { + key: 'rename', + label: t.sidebar.renameSession, + onClick: () => { + setRenameValue(session.title) + setRenameOpen(true) + } + }, + { + key: 'delete', + label: t.sidebar.deleteSession, + danger: true, + onClick: handleDeleteSession + } + ] + } + + // Bind the active highlight to the real browser URL (not NavLink's router + // `isActive`), so a session opened via the chat's mid-stream replaceState is + // highlighted immediately — the router location can lag the address bar. + const to = `/projects/${projectId}/sessions/${session.id}` + const active = useUrlPath() === to + // Hover-revealed by default; pinned visible on the open session — same rule as + // the project row above, so the highlighted row always carries its actions. + const rowActionClass = `!shrink-0 !transition-opacity ${ + active ? '' : '!opacity-0 group-hover:!opacity-100' + }` + return ( + <> + + {session.title} + {isRunning && ( + + )} + {/* More menu — the wrapper keeps a stray click off the row's link. + (IconButton already stops propagation itself, so this is belt and + braces rather than the thing that makes the menu work.) */} + e.stopPropagation()}> + + + } + variant="ghost" + size="xs" + className={rowActionClass} + /> + + + + + setRenameOpen(false)} + destroyOnHidden + > + setRenameValue(e.target.value)} + onPressEnter={handleRename} + /> + + + ) +} diff --git a/webui/frontend/app/components/messages/ArtifactFiles.tsx b/webui/frontend/app/components/messages/ArtifactFiles.tsx new file mode 100644 index 000000000..b992028d4 --- /dev/null +++ b/webui/frontend/app/components/messages/ArtifactFiles.tsx @@ -0,0 +1,67 @@ +import { FileCard } from '~/components/common/FileCard' +import { useT } from '~/lib/i18n' +import { useWorkspaceFileSet } from '~/lib/workspaceFiles' +import type { OnOpenFile } from './types' + +/** + * The turn's deliverables: workspace files the agent wrote/edited during its + * tool-call loop (`changed_files` from the loop_end boundary), rendered as + * file cards after the summary. Reuses the attachment FileCard styling + * (UserBubble's counterpart, left-aligned for the assistant side); cards open + * the file in the workspace rail, and a file the user has since deleted + * degrades to the disabled "deleted" card via the live workspace path set. + */ +export function ArtifactFiles({ + paths, + onOpenFile +}: { + paths: string[] + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const fileSet = useWorkspaceFileSet() + if (paths.length === 0) return null + + return ( +
+ {paths.map((path) => { + const name = path.split('/').pop() || path + // Unknown set (provider not mounted yet) → assume it exists; the set + // refresh flips the state as soon as it lands. + const deleted = fileSet ? !fileSet.has(path) : false + const card = ( + + ) + if (deleted || !onOpenFile) { + return ( +
+ {card} +
+ ) + } + return ( +
onOpenFile(path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFile(path) + } + }} + className="cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-msa-line-2" + > + {card} +
+ ) + })} +
+ ) +} diff --git a/webui/frontend/app/components/messages/AssistantMessage.tsx b/webui/frontend/app/components/messages/AssistantMessage.tsx new file mode 100644 index 000000000..ae1f73338 --- /dev/null +++ b/webui/frontend/app/components/messages/AssistantMessage.tsx @@ -0,0 +1,244 @@ +import { Markdown } from '~/components/common/Markdown' +import type { AgentMessage, AgentPart } from '~/lib/agentProvider' +import type { OnOpenStep, OnOpenFile } from './types' +import { ThoughtsFlow } from './ThoughtsFlow' +import { TaskPlan } from './TaskPlan' +import { ToolBatch } from './ToolBatch' +import { TurnProcess } from './TurnProcess' +import { splitTurn } from './turnSplit' +import { ArtifactFiles } from './ArtifactFiles' +import { ErrorCard } from './ErrorCard' +import { TurnPlan } from './TurnPlan' + +type StepPart = Extract +type RenderGroup = + | { kind: 'steps'; parts: StepPart[]; endIdx: number; group: unknown } + | { kind: 'single'; part: Exclude; endIdx: number } + +/** History-replayed approved authorizations render nothing (the adjacent + * tool step shows the invocation) — exclude them from grouping so the + * "used N tools" count matches what's actually visible. */ +function isHiddenStep(p: StepPart): boolean { + return ( + p.step.kind === 'authorization' && + String(p.step.meta.state ?? '') === 'approved' && + !p.step.meta.request_id + ) +} + +/** Group step parts by the SERVER-ASSIGNED tool-round id (`meta.group`): + * one assistant reply's tool-call set shares one id (stamped by the live + * mapper and history reconstruction alike) — the frontend only mirrors that + * set under a nested accordion, it never invents its own grouping. Steps + * without a group id (defensive fallback) render standalone. */ +function groupParts(parts: AgentPart[]): RenderGroup[] { + const groups: RenderGroup[] = [] + parts.forEach((part, i) => { + if (part.kind === 'step') { + const prev = groups[groups.length - 1] + const group = part.step.meta.group + if (isHiddenStep(part)) { + // Invisible, but must not split the round it sits inside. + if (prev?.kind === 'steps' && prev.endIdx === i - 1) prev.endIdx = i + return + } + if ( + group != null && + prev?.kind === 'steps' && + prev.endIdx === i - 1 && + prev.group === group + ) { + prev.parts.push(part) + prev.endIdx = i + } else { + groups.push({ kind: 'steps', parts: [part], endIdx: i, group }) + } + return + } + groups.push({ kind: 'single', part, endIdx: i }) + }) + return groups +} + +/** + * Rich assistant message. A turn is presented in two stages (design spec): + * + * - while it runs, every block sits FLAT under a live "processing Ns ..." + * header (no accordion — the user watches the work); + * - once the SDK closes the tool-call loop, the turn's trailing text block IS + * the final summary: it renders on its own, and everything before it folds + * into the collapsible "processed Ns" card above it. + * + * Falls back to plain markdown of `content` when a message has no parts (e.g. + * an error/fallback message). + */ +export function AssistantMessage({ + message, + streaming, + sessionId, + onOpenStep, + onOpenFile +}: { + message: AgentMessage + streaming: boolean + /** Backend session id — the plan chip fetches the session plan with it. */ + sessionId?: string | null + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile +}) { + const parts = message.parts + + if (!parts || parts.length === 0) { + return ( +
+ {/* A turn that has started but produced nothing yet still shows the + "processing Ns" header, so the counter is visible from 0 and grows + naturally. Without this the header first appeared with the first + part — after a slow model's think delay it popped in already + reading "8s". */} + {streaming && message.turnStartedAt != null && ( + + {null} + + )} + {message.content && ( + + )} +
+ ) + } + + // An interrupted turn produced no summary — it stays in the "processing" + // presentation forever (per spec), so the partial work isn't hidden behind a + // "processed" header that never really happened. + const { loopDone, processParts, summary } = splitTurn(parts, streaming) + + const renderParts = (source: AgentPart[], frozen: boolean) => + groupParts(source).map((g, gi) => { + // Expanded if it's the last meaningful block; auto-collapses when new + // parts arrive (checked against the ORIGINAL parts order). Blocks folded + // into the finished "processed" card are all history — keep them closed. + const isLast = + !frozen && + !source.slice(g.endIdx + 1).some((p) => p.kind !== 'interrupted') + if (g.kind === 'steps') { + // One server tool round (1..N calls) → one nested accordion. + return ( + + ) + } + const part = g.part + if (part.kind === 'thought') { + return ( + + ) + } + if (part.kind === 'tasks') { + // A conversation plan block is a frozen SNAPSHOT of the plan at that + // point in the stream (each update appends a new one) — never animated, + // even mid-turn. The composer's pinned panel is the live view. + return ( + + ) + } + if (part.kind === 'interrupted') { + return null + } + // Text: only the last text block gets the streaming cursor. + return ( + part.text && ( + + ) + ) + }) + + // changed_files carries the reserved "plan.md" marker when the loop rewrote + // the todo list (pairs with `plan_file`). The plan lives in the SESSION dir, + // not the workspace, so it is NOT a file card: it renders once more at the + // end as the flat TaskPlan list (TurnPlan). Out-of-workspace writes and + // custom-named plan files are already filtered server-side + // (sessions.changed_files_in_rows), so only the literal "plan.md" marker + // reaches here. + const deliverables = (message.changedFiles ?? []).filter( + (p) => p !== 'plan.md' + ) + const planTouched = + !!message.planFile || (message.changedFiles ?? []).includes('plan.md') + // This turn's FINAL plan state = the LAST plan snapshot the turn produced + // (each todo_write appends one). TurnPlan renders THIS per-turn state, not + // the session's current plan — an old turn's plan must not reflect a later + // turn's edits. Undefined for a render-only turn (no snapshot); TurnPlan then + // falls back to GET /plan. + const turnPlanTasks = [...parts] + .reverse() + .find((p): p is Extract => p.kind === 'tasks') + ?.tasks + + // Errors never fold into the "processed" accordion: a turn/API failure must + // be visible without expanding anything (a message that is ONLY an error + // would otherwise be an empty-looking collapsed card). + const errorParts = processParts.filter( + (p): p is Extract => p.kind === 'error' + ) + const foldableParts = processParts.filter((p) => p.kind !== 'error') + + return ( +
+ {/* Header shows while the turn RUNS (even before its first block) or once + it has folded content — never for a bare finished reply. */} + {(foldableParts.length > 0 || + (streaming && message.turnStartedAt != null)) && ( + + {renderParts(foldableParts, loopDone)} + + )} + {/* Alert cards sit outside the fold, before the summary. */} + {errorParts.map((p, i) => ( + + ))} + {summary && } + {/* The turn's deliverables (files written/edited this loop) close the + message, after the summary. */} + {loopDone && deliverables.length > 0 && ( + + )} + {/* The final todo plan is replayed once more as a flat task list (not a + file card), per the design — showing THIS turn's final state (the + per-turn snapshot), falling back to GET /plan only for a render-only + turn that produced no snapshot. */} + {loopDone && planTouched && ( + + )} +
+ ) +} diff --git a/webui/frontend/app/components/messages/ErrorCard.tsx b/webui/frontend/app/components/messages/ErrorCard.tsx new file mode 100644 index 000000000..4ad6b4162 --- /dev/null +++ b/webui/frontend/app/components/messages/ErrorCard.tsx @@ -0,0 +1,45 @@ +import { ExclamationCircleFilled } from '@ant-design/icons' +import { useT } from '~/lib/i18n' + +/** + * ErrorCard — a turn/API failure as its own alert block. + * + * Errors are NOT part of the reply: they get an alert-framed card (danger + * tokens, own icon + title) instead of being appended to the body text, so a + * failure never reads as something the agent said. Used by both the live + * stream and history replay (identical shape, no drift). + * + * `recoverable` distinguishes a failure the loop absorbed and kept going from + * one that ended the turn — the hint line makes that explicit instead of + * leaving the user guessing. + */ +export function ErrorCard({ + text, + recoverable = false +}: { + text: string + /** Whether the error re-entered the model context (the turn continued). */ + recoverable?: boolean +}) { + const { t } = useT() + if (!text) return null + return ( +
+
+ {/* No in-house alert glyph yet — antd fallback per the icon policy. */} + +
+
+ {recoverable ? t.chat.errorRecoverable : t.chat.errorTitle} +
+
+ {text} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/InlineCode.tsx b/webui/frontend/app/components/messages/InlineCode.tsx new file mode 100644 index 000000000..993006312 --- /dev/null +++ b/webui/frontend/app/components/messages/InlineCode.tsx @@ -0,0 +1,11 @@ +/** Inline-code style wrapper for dynamic entities inside step card headers + * (file paths, tool/MCP names, skill names, search queries…) — the visual + * analog of markdown backticks, on the msa fill token so it reads on both the + * fill-1 shell cards and the fill-2 accordion headers. */ +export function InlineCode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/webui/frontend/app/components/messages/MessageList.css b/webui/frontend/app/components/messages/MessageList.css new file mode 100644 index 000000000..1ed12d101 --- /dev/null +++ b/webui/frontend/app/components/messages/MessageList.css @@ -0,0 +1,12 @@ +/* MessageList.css */ +/* Copy button on HISTORY replies: hidden until the bubble is hovered (the + latest reply keeps it always visible — no msgl-copy-hover class there). + Targets antd's .ant-bubble wrapper, unreachable via classNames prop. */ +.msgl-copy-hover { + opacity: 0; + transition: opacity 0.15s ease; +} +.ant-bubble:hover .msgl-copy-hover, +.msgl-copy-hover:focus-within { + opacity: 1; +} diff --git a/webui/frontend/app/components/messages/MessageList.tsx b/webui/frontend/app/components/messages/MessageList.tsx new file mode 100644 index 000000000..47124bde7 --- /dev/null +++ b/webui/frontend/app/components/messages/MessageList.tsx @@ -0,0 +1,236 @@ +import { Bubble } from '@ant-design/x' +import { Tooltip } from 'antd' +import { CheckOutlined } from '@ant-design/icons' +import { + type ComponentRef, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' +import { useT } from '~/lib/i18n' +import type { AgentMessage } from '~/lib/agentProvider' +import { AssistantMessage } from './AssistantMessage' +import { splitTurn } from './turnSplit' +import { UserBubble } from './UserBubble' +import type { OnOpenStep, OnOpenFile } from './types' +import { IconButton } from '../common/IconButton' +import DownloadIcon from '~/assets/icons/download.svg?react' +import CopyIcon from '~/assets/icons/copy.svg?react' +import './MessageList.css' + +export interface ChatMessageItem { + id: string + message: AgentMessage + status: string +} + +/** Copy-reply action: on success the icon flips to a check for a moment + * instead of raising a toast — feedback stays inside the bubble footer. + * (CheckOutlined: no in-house check glyph asset yet, the established + * fallback.) */ +function CopyReplyButton({ text }: { text: string }) { + const { t } = useT() + const [copied, setCopied] = useState(false) + const timerRef = useRef | null>(null) + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, + [] + ) + const copy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopied(false), 2000) + } catch { + /* clipboard blocked (insecure context) — ignore */ + } + } + return ( + + + ) : ( + + ) + } + className="text-msa-text-3 hover:!text-msa-text-1" + onClick={() => void copy()} + /> + + ) +} + +/** Imperative handle so the host (ChatPanel) can jump the list to the latest + * message — e.g. the moment the user starts typing in the composer. */ +export interface MessageListHandle { + scrollToBottom: () => void +} + +interface BubbleContent { + message: AgentMessage + streaming: boolean +} + +/** + * Chat bubble list. Scrolling and auto-follow on new messages are delegated to + * Bubble.List's built-in scroll container (`autoScroll`) — no external overflow + * wrapper. The back-to-bottom button also rides on Bubble.List's own API: it + * toggles from the built-in scroll-box position and scrolls via the list ref's + * `scrollTo({ top: 'bottom' })`, so no hand-rolled scroll container is added. + */ +export const MessageList = forwardRef< + MessageListHandle, + { + items: ChatMessageItem[] + /** Backend session id, threaded to the plan chip (null until the first + * turn of a brand-new chat has created the session). */ + sessionId?: string | null + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile + } +>(function MessageList({ items, sessionId, onOpenStep, onOpenFile }, ref) { + const { t } = useT() + const listRef = useRef>(null) + const [showScrollDown, setShowScrollDown] = useState(false) + + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => { + // Guard: before the list renders its scroll box (empty conversation), + // Bubble.List's scrollTo destructures an undefined scrollBoxDom and + // throws ("Cannot destructure property 'scrollHeight'…"). + if (!listRef.current?.scrollBoxNativeElement) return + listRef.current.scrollTo({ top: 'bottom' }) + } + }), + [] + ) + + // Watch Bubble.List's built-in scroll-box. It uses a column-reverse viewport, + // so scrollTop is 0 at the visual bottom and grows negative when scrolling up + // to read history; show the button once we move away from the bottom. + useEffect(() => { + const box = listRef.current?.scrollBoxNativeElement + if (!box) return + const onScroll = () => setShowScrollDown(Math.abs(box.scrollTop) > 120) + onScroll() + box.addEventListener('scroll', onScroll, { passive: true }) + return () => box.removeEventListener('scroll', onScroll) + }, [items]) + + // The newest assistant bubble in the list — its copy button stays visible; + // all earlier replies only reveal theirs on hover. + const latestAssistantId = [...items] + .reverse() + .find(({ message }) => message.role === 'assistant')?.id + + const bubbleItems = items.map(({ id, message, status }) => { + const hasBody = !!message.content || (message.parts?.length ?? 0) > 0 + const inFlight = status === 'loading' || status === 'updating' + const interrupted = + message.role === 'assistant' && + message.parts?.some((p) => p.kind === 'interrupted') + // Copy target: the reply's FINAL summary only (the text after the + // "processed" fold) — mid-turn narration folded into the accordion is + // process detail, not the answer. Parts-less messages (error/fallback) + // copy their plain content; an interrupted turn has no summary → no button. + const copyText = + message.role === 'assistant' && !inFlight + ? message.parts?.length + ? (splitTurn(message.parts, false).summary?.text ?? '').trim() + : (message.content || '').trim() + : '' + // Only the LATEST reply keeps its copy button always visible; history + // replies reveal it on bubble hover (CSS: .msgl-copy-hover). + const isLatestReply = id === latestAssistantId + return { + key: id, + role: message.role, + content: { + message, + streaming: inFlight + } satisfies BubbleContent, + // Show the built-in loading indicator for the in-flight assistant bubble + // until it has actual body. `updating` (not just `loading`) is included + // because the turn's first frame is a metadata `session` frame that flips + // the status to `updating` while the body is still empty (backend still + // "thinking" before the first content token). + // Once the turn frame lands (`turnStartedAt`), AssistantMessage renders + // its own "processing Ns" header — the dots would then be a second, + // redundant progress hint stacked above it. + loading: inFlight && !hasBody && message.turnStartedAt == null, + footer: + copyText || interrupted ? ( +
+ {/* Leftmost action: copy the reply text. */} + {copyText ? ( + + + + ) : ( + + )} + {interrupted && ( + + {t.chat.interrupted} + + )} +
+ ) : undefined + } + }) + + return ( +
+ ( + + ) + }, + assistant: { + placement: 'start', + variant: 'borderless', + contentRender: (content: BubbleContent) => ( + + ) + } + }} + items={bubbleItems} + /> + {showScrollDown && ( + + { + if (!listRef.current?.scrollBoxNativeElement) return + listRef.current.scrollTo({ top: 'bottom', behavior: 'smooth' }) + }} + className="absolute bottom-5 right-5 z-10 !rounded-full" + variant="tonal" + icon={} + > + + )} +
+ ) +}) diff --git a/webui/frontend/app/components/messages/MessageListSkeleton.tsx b/webui/frontend/app/components/messages/MessageListSkeleton.tsx new file mode 100644 index 000000000..5b1e255ca --- /dev/null +++ b/webui/frontend/app/components/messages/MessageListSkeleton.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from 'antd' +import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' +import type { ChatMessageItem } from './MessageList' + +/** + * One skeleton row mimicking a chat bubble: a rounded content block, no avatar + * (the real Bubble.List renders none). `mine` flips it to the right (user) + * side, matching the real placement (assistant start / user end). + */ +function SkeletonRow({ + mine, + rows, + width +}: { + mine?: boolean + rows: number + width: number +}) { + return ( +
+
+ +
+
+ ) +} + +/** Approximate the bubble's line count from its content length. */ +function estimateRows(len: number): number { + return Math.max(1, Math.min(4, Math.ceil(len / 80))) +} + +/** Approximate the bubble's width from its content length (side-capped). */ +function estimateWidth(len: number, mine: boolean): number { + const max = mine ? 280 : 440 + return Math.min(max, Math.max(120, len * 7)) +} + +/** + * Loading placeholder for the chat message list, shown while history hydrates + * (see ChatPanel). Renders one skeleton bubble per real message so the count + * and left/right placement (by `role`) match the conversation about to appear. + * Occupies the same flex-1 slot as MessageList (the surrounding chat layout + * provides the centered column and the SSR-safe composer stays mounted below). + */ +export function MessageListSkeleton({ items }: { items: ChatMessageItem[] }) { + return ( + + {items.map(({ id, message }) => { + const mine = message.role === 'user' + const len = message.content?.length ?? 0 + return ( + + ) + })} + + ) +} diff --git a/webui/frontend/app/components/messages/StepCard.tsx b/webui/frontend/app/components/messages/StepCard.tsx new file mode 100644 index 000000000..0005048c0 --- /dev/null +++ b/webui/frontend/app/components/messages/StepCard.tsx @@ -0,0 +1,502 @@ +import { Typography } from 'antd' +import { FileTypeIcon } from '~/components/common/FileCard' +import { useT } from '~/lib/i18n' +import { useFileExists } from '~/lib/workspaceFiles' +import { InlineCode } from './InlineCode' +import { faviconOf, parseWebSearchResults } from './searchResults' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep, OnOpenFile } from './types' +import { TerminalStepCard } from './steps/TerminalStepCard' +import { ToolCallStepCard } from './steps/ToolCallStepCard' +import { ArtifactStepCard } from './steps/ArtifactStepCard' +import { StepCardShell, StepInProgressRow } from './steps/StepCardShell' +import { AuthConfirmStepCard } from './steps/AuthConfirmStepCard' +import LoadSkillIcon from '~/assets/icons/load-skill.svg?react' +import SearchIcon from '~/assets/icons/search.svg?react' +import MemoryIcon from '~/assets/icons/memory.svg?react' +import JumpIcon from '~/assets/icons/jump.svg?react' +import GlobeIcon from '~/assets/icons/globe.svg?react' + +/** File step card ("modified: x" / "read: x") with LIVE existence: a webui + * rename/delete flips it to the disabled "deleted" card immediately (via the + * workspace file-set context), no reload needed. */ +function FileStepCard({ + path, + label, + serverExists, + onOpen +}: { + path: string + label: string + serverExists: boolean + onOpen: () => void +}) { + const { t } = useT() + const exists = useFileExists(path, serverExists) + const icon = + if (!exists) { + return ( + + {label}: + {path} + + ) + } + return ( + + {label}: + {path} + + ) +} + +/** One file row INSIDE the multi-file wrapper card (no border of its own — the + * wrapper owns the border; the row is just a clickable line with hover bg and + * its own live deleted state). */ +function MultiFileRow({ + path, + label, + serverExists, + onOpen +}: { + path: string + label: string + serverExists: boolean + onOpen: () => void +}) { + const { t } = useT() + const exists = useFileExists(path, serverExists) + const icon = + const inner = ( + <> + + {icon} + + + {label}: + {path} + + + ) + if (!exists) { + return ( +
+ {inner} + + {t.home.fileDeleted} + +
+ ) + } + return ( + + ) +} + +/** Wrapper for a SINGLE tool call that read/edited SEVERAL files: one bordered + * card holding the file rows flat inside, so it reads as one tool invocation + * (matching the "used 1 tool" count) rather than N separate tool cards. */ +function MultiFileStepCard({ + paths, + label, + serverExists, + onOpen +}: { + paths: string[] + label: string + serverExists: boolean + onOpen: (path: string) => void +}) { + return ( +
+ {paths.map((p, i) => ( + onOpen(p)} + /> + ))} +
+ ) +} + +/** Dispatches a step to the correct card by its kind. + * + * A step still executing (`meta.status === "running"`, emitted on + * tool_call_started) is NOT a card of its own: it dispatches to the very card + * its result will land in, and that card renders its own in-progress state (a + * spinner in place of its leading glyph). One card per tool call, from start to + * result — no separate placeholder that swaps out mid-flight. */ +export function StepCard({ + step, + onOpenStep, + onOpenFile, + isLast +}: { + step: AgentStep + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile + /** Whether this step is the last meaningful part of its message — accordion + * cards default expanded while last, auto-collapse once newer parts arrive. */ + isLast?: boolean +}) { + const { t } = useT() + const meta = step.meta + const open = () => onOpenStep?.(step) + const running = meta.status === 'running' + + switch (step.kind) { + case 'terminal': + return ( + + ) + case 'artifact': + return + case 'authorization': { + // History-replayed approved authorizations (no request_id) don't render — + // the adjacent tool_call step already shows the full invocation. LIVE + // approved cards (request_id present) stay visible while the tool runs; + // agentProvider replaces them in place when the result step arrives. + const authState = (meta.state as string) ?? 'pending' + if (authState === 'approved' && !meta.request_id) return null + return + } + case 'tool_call': + return ( + + ) + case 'skill_load': + return ( + + ) : ( + + ) + } + title={ + <> + {t.chat.stepLoadSkill}{' '} + {String(meta.name ?? '')} + + } + titleText={`${t.chat.stepLoadSkill} ${String(meta.name ?? '')}`} + /> + ) + case 'skill_list': { + // Same SDK tool for both: a `query` makes it a search, without one it's a + // plain catalog listing. + const query = String(meta.query ?? '') + const label = query ? t.chat.stepSkillSearch : t.chat.stepSkillList + return ( + } + title={ + query ? ( + <> + {label}{' '} + {query} + + ) : ( + label + ) + } + titleText={query ? `${label} ${query}` : label} + /> + ) + } + case 'skill_manage': { + const action = String(meta.action ?? '') + const label = + action === 'create' + ? t.chat.stepSkillCreate + : action === 'edit' + ? t.chat.stepSkillEdit + : action === 'delete' + ? t.chat.stepSkillDelete + : t.chat.stepSkillManage + const skill = String(meta.skill ?? '') + return ( + } + title={ + skill ? ( + <> + {label}{' '} + {skill} + + ) : ( + label + ) + } + titleText={skill ? `${label} ${skill}` : label} + /> + ) + } + case 'file_read': + case 'file_write': + case 'file_edit': { + const path = String(meta.path ?? '') + // Distinct label per file operation: read / full-content write / + // in-place edit — the three render as distinguishable cards. + const label = + step.kind === 'file_read' + ? t.chat.stepFileRead + : step.kind === 'file_edit' + ? t.chat.stepFileEdit + : t.chat.stepFileWrite + // The accordion form of this card, used whenever the operation needs more + // than a one-line row: the authorization ask (three decision buttons), a + // refusal, or a failure (the error text). It keeps the file's identity — + // that file type's glyph + the operation named on the path — instead of + // falling back to "call tool file_system---write_file" plus a JSON blob. + const askLabel = + step.kind === 'file_read' + ? t.chat.stepFileReadAsk + : step.kind === 'file_edit' + ? t.chat.stepFileEditAsk + : t.chat.stepFileWriteAsk + const fileAccordion = ( + } + title={ + <> + {askLabel}{' '} + {path} + + } + titleText={`${askLabel} ${path}`} + /> + ) + // An ask (or a refusal) has no room in a one-line row for the decision + // buttons / the rejected badge. Titled with the pre-action verb, since + // nothing has happened yet. + const askState = String(meta.state ?? '') + if (askState === 'pending' || askState === 'rejected') return fileAccordion + // In progress: "reading/writing/editing {path}", not the past-tense row. + // Either the live `running` frame, or an ask already approved whose result + // hasn't landed (what an attach replay hands back after a refresh). + if ( + running || + (askState === 'approved' && !!meta.request_id && !meta.result) + ) { + return + } + // Failed (interrupted, permission denied, write error …): the accordion + // shows what was attempted plus the error, under a "call failed" badge. + if (meta.status === 'error') return fileAccordion + // A multi-file read/edit is ONE tool call: wrap its files in a single + // bordered card (rows flat inside), so it reads as one invocation and + // matches the "used N tools" count — not N separate tool cards. Each row + // is still individually clickable + deleted-flagged. + // + // SEVERAL files, though: the tool also accepts `paths` with a single entry, + // and wrapping that produced a visibly different card from the same + // operation called with `path` — an inset borderless row inside a frame, + // instead of the normal one-line card. One file, one plain card. + const multi = Array.isArray(meta.paths) + ? (meta.paths as unknown[]).map(String).filter(Boolean) + : [] + if (multi.length > 1) { + return ( + (onOpenFile ? onOpenFile(p) : open())} + /> + ) + } + return ( + (onOpenFile ? onOpenFile(path) : open())} + /> + ) + } + case 'browser': { + const title = String(meta.title ?? meta.url ?? '') + return ( + } + title={ + <> + {t.chat.stepBrowser}{' '} + {title} + + } + titleText={`${t.chat.stepBrowser} ${title}`} + /> + ) + } + case 'search': { + const query = String(meta.query ?? '') + // File-scoped searches (grep/glob) keep the inline accordion; WEB + // searches are a one-line card that opens the result list in the right + // rail (globe icon, result count + stacked favicons once finished). + if (String(meta.scope ?? '') === 'files') { + return ( + } + title={ + <> + {t.chat.stepSearchFiles}{' '} + {query} + + } + titleText={`${t.chat.stepSearchFiles} ${query}`} + /> + ) + } + const results = parseWebSearchResults(meta.result) + // An ask (or a refusal) needs the accordion: room for the three decision + // buttons / the rejected badge, which a one-line row has no space for. The + // ask lands on THIS card rather than a generic "call tool" one (backend + // _AUTH_INLINE_KINDS), so the user reads the query, not `web_search---x`. + const askState = String(meta.state ?? '') + if (askState === 'pending' || askState === 'rejected') { + return ( + } + title={ + <> + {t.chat.stepSearch}{' '} + {query} + + } + titleText={`${t.chat.stepSearch} ${query}`} + /> + ) + } + // Still searching: the one-line row states it's in progress and is NOT + // openable — there is no result list behind it yet. Two ways to be here: + // the live `running` frame, OR an ASK already approved whose result hasn't + // landed (what an attach replay after a page refresh hands back — those + // frames carry the resolved ask, not a `running` status). + if ( + running || + (askState === 'approved' && !!meta.request_id && !meta.result) + ) { + return + } + const favicons = results + .map((r) => faviconOf(r.url)) + .filter(Boolean) + .slice(0, 3) + // A refusal is NOT a failure: a denied call's errored result reads "Tool + // call denied", so it gets the rejection wording in the muted tone — red + // "call failed" is reserved for searches that actually tried and broke + // (timeout, interruption, upstream error). + const denied = + askState === 'rejected' || + (meta.status === 'error' && + /denied/i.test(String(meta.error ?? meta.result ?? ''))) + const failed = meta.status === 'error' && !denied + return ( + } + onClick={open} + // The outcome belongs on the ROW itself — the error text only lives in + // the rail, which the user has to open to see. + note={ + denied + ? t.chat.authRejected + : failed + ? t.chat.callFailed + : undefined + } + noteTone={denied ? 'muted' : 'danger'} + tipText={ + results.length > 0 + ? `${t.chat.stepSearch} ${query} ${t.chat.searchedPages.replace( + '{n}', + String(results.length) + )}` + : `${t.chat.stepSearch} ${query}` + } + > + {t.chat.stepSearch}{' '} + {query} + {results.length > 0 && ( + <> + {' '} + + {t.chat.searchedPages.replace('{n}', String(results.length))} + + + {favicons.map((src, i) => ( + 0 ? '-ml-1.5' : '' + }`} + onError={(e) => { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + ))} + + + )} + + ) + } + case 'memory': { + const label = + String(meta.action ?? '') === 'read' + ? t.chat.stepMemoryRead + : t.chat.stepMemory + return ( + } + title={label} + /> + ) + } + default: + return null + } +} diff --git a/webui/frontend/app/components/messages/StepDetailRail.tsx b/webui/frontend/app/components/messages/StepDetailRail.tsx new file mode 100644 index 000000000..420e43232 --- /dev/null +++ b/webui/frontend/app/components/messages/StepDetailRail.tsx @@ -0,0 +1,424 @@ +import type { ReactNode } from 'react' +import { IconButton } from '~/components/common/IconButton' +import { useT } from '~/lib/i18n' +import type { Dict } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import { faviconOf, hostOf, parseWebSearchResults } from './searchResults' +import JumpIcon from '~/assets/icons/jump.svg?react' +import GlobeIcon from '~/assets/icons/globe.svg?react' +import CloseIcon from '~/assets/icons/close.svg?react' + +/** Human title for the rail header, per step kind. */ +function titleFor(step: AgentStep, t: Dict): string { + const meta = step.meta + const s = (v: unknown) => String(v ?? '') + switch (step.kind) { + case 'file_read': + return `${t.chat.stepFileRead}:${s(meta.path)}` + case 'file_write': + return `${t.chat.stepFileWrite}:${s(meta.path)}` + case 'file_edit': + return `${t.chat.stepFileEdit}:${s(meta.path)}` + case 'skill_load': + return `${t.chat.stepLoadSkill} ${s(meta.name)}` + case 'skill_list': + return meta.query + ? `${t.chat.stepSkillSearch} ${s(meta.query)}` + : t.chat.stepSkillList + case 'skill_manage': { + const action = s(meta.action) + const label = + action === 'create' + ? t.chat.stepSkillCreate + : action === 'edit' + ? t.chat.stepSkillEdit + : action === 'delete' + ? t.chat.stepSkillDelete + : t.chat.stepSkillManage + return meta.skill ? `${label} ${s(meta.skill)}` : label + } + case 'browser': + return `${t.chat.stepBrowser} ${s(meta.title ?? meta.url)}` + case 'terminal': + return t.chat.stepTerminal + case 'search': { + // Web searches title with the result count once finished (design spec: + // "found N pages"); otherwise fall back to the query. + const n = parseWebSearchResults(meta.result).length + if (n > 0) return t.chat.searchedPages.replace('{n}', String(n)) + return `${t.chat.stepSearch} ${s(meta.query)}` + } + case 'memory': + return s(meta.action) === 'read' + ? t.chat.stepMemoryRead + : t.chat.stepMemory + case 'authorization': + return t.chat.stepAuthTitle + case 'artifact': + return s(meta.name) + default: + return `${t.chat.stepInvoke} ${s(meta.tool ?? meta.name)}` + } +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ) +} + +function CodeBlock({ + children, + tone = 'default' +}: { + children: ReactNode + tone?: 'default' | 'error' +}) { + return ( +
+      {children}
+    
+ ) +} + +function InlineCode({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function EmptyHint({ label }: { label: string }) { + return
{label}
+} + +const asStr = (v: unknown): string => (typeof v === 'string' ? v : '') + +const asRecord = (v: unknown): Record => + v && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : {} + +/** + * Search result cards (web_search unified shape), per the design spec: each + * card shows a site header (favicon + hostname + jump chevron), the page + * title, and — when present — a clamped summary. The whole card opens the + * url. Falls back to the raw payload (e.g. file_system grep/glob text) when + * it isn't a `{results:[...]}` object. + */ +function SearchResults({ raw }: { raw: string }) { + const results = parseWebSearchResults(raw) + if (results.length === 0) { + return {raw} + } + return ( +
+ {results.map((r, i) => { + const host = hostOf(r.url) + const favicon = faviconOf(r.url) + const inner = ( + <> + {/* Site header row, separated from the content by a divider */} +
+ {favicon ? ( + { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + ) : ( + + )} + + {host || r.url} + + {r.url && ( + + )} +
+
+ {(r.title || r.url) && ( +
+ {r.title || r.url} +
+ )} + {r.summary && ( +
+ {r.summary} +
+ )} +
+ + ) + return r.url ? ( + + {inner} + + ) : ( +
+ {inner} +
+ ) + })} +
+ ) +} + +/** Extract readable file content from a read_file result (raw text, a + * `{path: content}` map, or a `{type:"file_unchanged", message}` note). */ +function fileReadContent(result: string): string { + try { + const obj = JSON.parse(result) + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + const rec = obj as Record + if (typeof rec.message === 'string' && rec.type) return rec.message + const strings = Object.values(rec).filter( + (v): v is string => typeof v === 'string' + ) + if (strings.length) return strings.join('\n\n') + } + } catch { + /* not JSON — show as-is */ + } + return result +} + +/** Per-kind body: the same clicked-step data rendered differently by type. */ +function StepDetailBody({ step, t }: { step: AgentStep; t: Dict }) { + const meta = step.meta ?? {} + const argRec = asRecord(meta.arguments) + const hasArgs = Object.keys(argRec).length > 0 + const result = asStr(meta.result) + const error = asStr(meta.error) + const errorSection = error ? ( +
+ {error} +
+ ) : null + + switch (step.kind) { + case 'search': { + const query = asStr(meta.query) + return ( + <> + {query && ( +
+ {query} +
+ )} + {result && ( +
+ +
+ )} + {errorSection} + {!query && !result && !error && ( + + )} + + ) + } + case 'memory': { + const content = asStr(argRec.content) || asStr(argRec.new_content) + return ( + <> + {content && ( +
+ {content} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!content && !result && !error && ( + + )} + + ) + } + case 'terminal': { + const code = + asStr(meta.code) || asStr(argRec.command) || asStr(argRec.code) + return ( + <> + {code && ( +
+ {code} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!code && !result && !error && ( + + )} + + ) + } + case 'file_read': + case 'file_write': + case 'file_edit': { + // The path is already in the header title; show the body content. + if (step.kind === 'file_read') { + const content = fileReadContent(result) + return ( + <> + {content && ( +
+ {content} +
+ )} + {errorSection} + {!content && !error && } + + ) + } + // file_write carries a full-content write (`content`); file_edit a + // diff-style old->new replacement. Argument names vary across tool + // dialects, so alias them all; otherwise an edit shows nothing (it + // carries no `content`). + const content = asStr(argRec.content) || asStr(argRec.file_text) + const oldText = + asStr(argRec.old) || asStr(argRec.old_string) || asStr(argRec.old_str) + const newText = + asStr(argRec.new) || asStr(argRec.new_string) || asStr(argRec.new_str) + const hasBody = !!(content || oldText || newText) + return ( + <> + {content && ( +
+ {content} +
+ )} + {!content && oldText && ( +
+ {oldText} +
+ )} + {!content && newText && ( +
+ {newText} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!hasBody && !result && !error && ( + + )} + + ) + } + default: { + // Generic tool_call / browser / skill_load / artifact / authorization: + // the full invocation as tool + arguments + result. + const tool = asStr(meta.tool) || asStr(meta.name) + const empty = !tool && !hasArgs && !result && !error + return ( + <> + {tool && ( +
+ {tool} +
+ )} + {hasArgs && ( +
+ {JSON.stringify(argRec, null, 2)} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {empty && } + + ) + } + } +} + +/** + * Right-side squeezable rail showing the full detail of a clicked step card. + * Mirrors the workspace rail (SessionRightRail) layout — a filling column with a + * header (title + close) over a scrollable body — but is a dedicated component + * that renders each tool kind (search / memory / terminal / file / generic) + * differently, not the file workspace. + */ +export function StepDetailRail({ + step, + onClose, + clip = false +}: { + step: AgentStep + onClose: () => void + /** Clip the scrollable body (overflow hidden) while the rail is animating, so + * its content doesn't reflow or flash a scrollbar mid-transition. */ + clip?: boolean +}) { + const { t } = useT() + const title = titleFor(step, t) + + return ( +
+ {/* Header */} +
+

+ {title} +

+ } + variant="tonal" + size="sm" + onClick={onClose} + /> +
+ + {/* Body */} +
+
+ +
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/TaskPlan.tsx b/webui/frontend/app/components/messages/TaskPlan.tsx new file mode 100644 index 000000000..d77667405 --- /dev/null +++ b/webui/frontend/app/components/messages/TaskPlan.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentTask } from '~/lib/agentProvider' +import TodoIcon from '~/assets/icons/todo.svg?react' +import TaskDoneIcon from '~/assets/icons/task-done.svg?react' +import TaskRunningIcon from '~/assets/icons/task-running.svg?react' +import TaskPausedIcon from '~/assets/icons/task-paused.svg?react' +import TaskWaitingIcon from '~/assets/icons/task-waiting.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/** Same status glyph set as the composer's thinking plan list (mirrored so + * both task lists read identically) — the design-spec circled icons, colored + * via currentColor so one asset covers light & dark. A "running" item without + * a live turn is stale plan-file state (e.g. an interrupted turn) — degrade + * to the paused glyph. */ +export function taskStatusIcon( + status: AgentTask['status'], + streaming: boolean +) { + switch (status) { + case 'done': + return + case 'running': + return streaming ? ( + + ) : ( + + ) + case 'pending': + return + default: + return + } +} + +/** + * Todo-plan card as an inline accordion (per the design spec): the header + * ("todo tasks" + done/total) toggles a timeline-style task list — status + * circles joined by a dashed spine. No right-rail detail anymore. + * + * Expansion follows the shared accordion convention: open while it's the + * message's LAST meaningful block, auto-collapses once newer parts arrive, + * manual toggling always available. + */ +export function TaskPlan({ + tasks, + isLast, + streaming = false +}: { + tasks: AgentTask[] + isLast?: boolean + /** Whether a turn is live — gates the animated "running" spinner. */ + streaming?: boolean +}) { + const { t } = useT() + const [expanded, setExpanded] = useState(isLast ?? false) + + useEffect(() => { + if (!isLast) setExpanded(false) + }, [isLast]) + + if (tasks.length === 0) return null + + const done = tasks.filter((task) => task.status === 'done').length + const total = tasks.length + + return ( +
+ {/* Header */} + + + {/* Body: timeline list with a dashed spine between status circles */} +
+
+
+ {tasks.map((task, i) => ( +
+ {/* Status circle + dashed connector down to the next item */} +
+ + {taskStatusIcon(task.status, streaming)} + + {i < tasks.length - 1 && ( + + )} +
+
+ {task.label} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/ThoughtsFlow.css b/webui/frontend/app/components/messages/ThoughtsFlow.css new file mode 100644 index 000000000..d0780ff7f --- /dev/null +++ b/webui/frontend/app/components/messages/ThoughtsFlow.css @@ -0,0 +1,36 @@ +/* ThoughtsFlow.css */ +/* Reasoning body renders through the shared , whose typography theme + sets its own text color and 14px base. Reasoning is secondary content, so + re-scope it to the muted token and the surrounding small size. + + Chosen over `transform: scale()` + `opacity`: scaling renders text on + fractional pixels (visibly soft), needs a width/offset hack to undo the + layout the un-scaled box still occupies, and `opacity` fades the WHOLE + subtree — code blocks and links lose their contrast and brand color with it. + A real font-size plus a real color token keeps code and links crisp. + + Belt-and-braces on the color: set the theme's own variable AND target the + elements it colors (p / li / td), so a variable rename upstream can't + silently restore full-contrast body text. Prefix `tf-` per the + component-scoped CSS rule. */ +.tf-reasoning .msa-md-body { + --text-color: var(--msa-text-3); + --heading-color: var(--msa-text-3); + font-size: 0.875rem; +} + +.tf-reasoning .msa-md-body p, +.tf-reasoning .msa-md-body li, +.tf-reasoning .msa-md-body td { + color: var(--msa-text-3); +} + +/* Tighten the vertical rhythm: a thought block sits inside a collapsible row, + so the theme's article-style paragraph spacing is too airy here. */ +.tf-reasoning .msa-md-body > :first-child { + margin-top: 0; +} + +.tf-reasoning .msa-md-body > :last-child { + margin-bottom: 0; +} diff --git a/webui/frontend/app/components/messages/ThoughtsFlow.tsx b/webui/frontend/app/components/messages/ThoughtsFlow.tsx new file mode 100644 index 000000000..6b7d10515 --- /dev/null +++ b/webui/frontend/app/components/messages/ThoughtsFlow.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import { Markdown } from '~/components/common/Markdown' +import ThinkingIcon from '~/assets/icons/thinking.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' +import './ThoughtsFlow.css' + +/** Format elapsed seconds as "Ns" (< 60s) or "Nm Ns" (per the design). */ +function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +/** + * A single reasoning block: a "thinking Ns..." header (a live counter that ticks + * up while streaming, then freezes at the reported duration) followed by the + * gray reasoning text. One is rendered per `thought` part, in stream order. + * + * While the model is still thinking (`!done`) the reasoning stays visible and + * cannot be collapsed, and the header counts up from `startedAt`. Once thinking + * finishes (`done`) it defaults to collapsed and shows the frozen elapsed time. + */ +export function ThoughtsFlow({ + text, + startedAt, + duration, + done, + isLast +}: { + text: string + startedAt?: number + duration?: number + done?: boolean + /** Whether this thought is currently the last meaningful part in the message. + * When true → expanded; when it becomes false (new parts arrived) → auto-collapse. */ + isLast?: boolean +}) { + const { t } = useT() + // done=false → live streaming; done=undefined (server history) or true → complete. + const isDone = done !== false + + const [expanded, setExpanded] = useState(isLast ?? false) + + // Auto-collapse when this thought is no longer the last part (streaming + // pushed new content after it). + useEffect(() => { + if (isDone && !isLast) setExpanded(false) + }, [isDone, isLast]) + + // Live elapsed seconds, ticked once a second while thinking is in flight. + const [elapsed, setElapsed] = useState(() => + startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0 + ) + useEffect(() => { + if (done || !startedAt) return + const tick = () => + setElapsed(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [done, startedAt]) + + if (!text) return null + + // Finished → the reported duration; live → the ticking counter. + // + // `duration` is in WHOLE SECONDS and 0 is a real value: the SDK rounds down, + // so any sub-second block legitimately reports 0. Only `null`/absent means + // "unknown" — a block cut short by Stop never runs its end callback, and the + // replayed row then carries no duration at all (see `_history_step`, which + // passes the number through and only falls back to None when the field is + // missing). Suppressing 0 as if it were unknown hid the time on every quick + // thought, which is most of them. + const shown = isDone ? (duration ?? undefined) : elapsed + const timing = shown != null ? ` ${formatDuration(shown)}` : '' + const header = isDone + ? `${t.chat.thoughts}${timing}` + : `${t.chat.thoughts}${timing} ...` + + // Live thinking → always shown; finished → collapsed unless the user expands. + // `done` is explicitly `false` only during live streaming; `undefined` or `true` + // (from server history) means the thought is complete → default collapsed. + const showContent = !isDone || expanded + return ( +
+ {isDone ? ( +
setExpanded((v: boolean) => !v)} + className="flex cursor-pointer items-center gap-1.5 text-sm text-msa-text-2" + > + + {header} + +
+ ) : ( +
+ + {header} +
+ )} +
+
+ {/* Reasoning is model output too (lists, emphasis, code spans, links), + so it goes through the shared Markdown renderer rather than being + dumped as pre-wrapped plain text. `streaming` while the block is + still open, so partial syntax isn't parsed as broken markup. */} +
+ +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/ToolBatch.tsx b/webui/frontend/app/components/messages/ToolBatch.tsx new file mode 100644 index 000000000..8ebcbc412 --- /dev/null +++ b/webui/frontend/app/components/messages/ToolBatch.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentPart } from '~/lib/agentProvider' +import { StepCard } from './StepCard' +import type { OnOpenStep, OnOpenFile } from './types' +import TodoIcon from '~/assets/icons/todo.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +type StepPart = Extract + +/** + * Outer accordion wrapping one server tool-call round (1..N consecutive tool + * steps): header reads "used N tools", the body holds the individual step + * cards (their own UI/accordions unchanged). + * + * Expansion mirrors the ThoughtsFlow convention: open while it's the + * message's LAST meaningful block (the in-flight round streams visibly), + * auto-collapses once newer parts arrive; a pending authorization inside + * pins it open (the approve/reject buttons must stay reachable). + */ +export function ToolBatch({ + steps, + isLast, + onOpenStep, + onOpenFile +}: { + steps: StepPart[] + isLast: boolean + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const hasPending = steps.some( + (p) => String(p.step.meta.state ?? '') === 'pending' + ) + const [expanded, setExpanded] = useState(isLast || hasPending) + + useEffect(() => { + if (!isLast && !hasPending) setExpanded(false) + }, [isLast, hasPending]) + + return ( +
+
setExpanded((v) => !v)} + className="flex cursor-pointer items-center gap-1.5 text-sm font-medium text-msa-text-2" + > + + {t.chat.useTools.replace('{n}', String(steps.length))} + +
+
+
+
+ {steps.map((p, i) => ( + + ))} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/TurnPlan.tsx b/webui/frontend/app/components/messages/TurnPlan.tsx new file mode 100644 index 000000000..962c348b6 --- /dev/null +++ b/webui/frontend/app/components/messages/TurnPlan.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { api } from '~/lib/api' +import type { AgentTask, TaskStatus } from '~/lib/agentProvider' +import { TaskPlan } from './TaskPlan' + +/** Narrow the server's plan status strings (already frontend-mapped by + * sessions._plan_part) into the AgentTask status union. */ +function toAgentTasks( + tasks: { id: string; label: string; status: string }[] +): AgentTask[] { + return tasks.map((t) => ({ + id: t.id, + label: t.label, + status: (['done', 'running', 'pending'].includes(t.status) + ? t.status + : 'pending') as TaskStatus + })) +} + +/** + * Final plan recap at the end of a finished turn: when the loop rewrote the + * todo list (reserved "plan.md" changed_files entry / `plan_file`), the plan + * is shown ONE more time here as the flat TaskPlan accordion — same widget the + * conversation uses inline — instead of a file card. The plan lives in the + * SESSION dir, not the workspace, so it never goes through the file card / + * exists-check. + * + * Scope is PER-TURN: `tasks` is THIS turn's final plan snapshot (the last + * `tasks` part on the message). It is rendered directly so an old turn's recap + * shows the plan as it was at THAT turn — never a later turn's edits. Only a + * render-only turn (todo_render_md without a todo_write → no snapshot) leaves + * `tasks` undefined; then it falls back to the session's current plan via + * GET /sessions/{id}/plan. Renders nothing when there's neither. + */ +export function TurnPlan({ + tasks, + sessionId +}: { + /** This turn's final plan snapshot; rendered directly when present. */ + tasks?: AgentTask[] + /** Fallback source for a render-only turn (no snapshot). */ + sessionId?: string +}) { + const [fetched, setFetched] = useState(null) + const hasSnapshot = tasks !== undefined + + useEffect(() => { + // Only fetch as a fallback: a per-turn snapshot needs no request. + if (hasSnapshot || !sessionId) return + let cancelled = false + api + .getSessionPlan(sessionId, { silent: true }) + .then((plan) => { + if (!cancelled) setFetched(toAgentTasks(plan.tasks)) + }) + .catch(() => { + if (!cancelled) setFetched([]) + }) + return () => { + cancelled = true + } + }, [hasSnapshot, sessionId]) + + const plan = hasSnapshot ? tasks : fetched + if (!plan || plan.length === 0) return null + + // isLast → the recap opens expanded (it closes the message); a frozen + // snapshot, so no live spinner. + return +} diff --git a/webui/frontend/app/components/messages/TurnProcess.tsx b/webui/frontend/app/components/messages/TurnProcess.tsx new file mode 100644 index 000000000..101044465 --- /dev/null +++ b/webui/frontend/app/components/messages/TurnProcess.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import TaskIcon from '~/assets/icons/task.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/** Format elapsed seconds as "Ns" (< 60s) or "Nm Ns" (per the design). */ +function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +/** + * Turn-level process wrapper: everything a turn produced BEFORE its final + * summary answer lives under one header. + * + * - While the turn runs (`done === false`) the header reads "processing Ns ..." + * with a live counter and the blocks below stay FLAT — no chevron, not + * collapsible (the user watches the work happen). + * - Once the SDK closes the tool-call loop (`loop_end` → the turn's final + * assistant text becomes the summary) the header flips to "processed Ns", + * becomes a collapsible accordion (collapsed by default) and its content is + * wrapped in a bordered card; the summary renders after it, outside. + * - An interrupted turn never gets a summary, so it keeps the flat + * "processing" presentation with its frozen elapsed time. + */ +export function TurnProcess({ + done, + live, + startedAt, + durationMs, + children +}: { + /** Loop finished → collapsible "processed" header + bordered content. */ + done: boolean + /** Turn still streaming → tick the counter. */ + live: boolean + /** Epoch ms of the turn's first frame (live counter base). */ + startedAt?: number + /** Server-reported loop duration; preferred once done. */ + durationMs?: number + children: React.ReactNode +}) { + const { t } = useT() + const [expanded, setExpanded] = useState(false) + + // Live elapsed seconds, ticked once a second while the turn is in flight. + // FLOOR (not round) so the number matches wall-clock reading and formatDuration + // — rounding showed "1s" at 0.5s and could tick backwards on timer drift. + const [elapsed, setElapsed] = useState(() => + startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0 + ) + useEffect(() => { + if (!live || !startedAt) return + const tick = () => + setElapsed(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [live, startedAt]) + + // Prefer the server's loop duration (authoritative and identical across live / + // replay); while the turn runs, fall back to the live tick. `elapsed` is + // rendered even at 0 — hiding it for the first second made the header text + // change width one tick later, which read as a jitter. + // + // A FINISHED turn with no server duration shows NO time at all. Replayed + // history carries no `startedAt`, so the live tick is a constant 0 there: + // printing it produced "done 0s" on turns whose own thought block reported + // 1s — an outer total smaller than a step inside it. Turns persisted without a + // `loop_end` marker (interrupts, and every round written before that marker + // existed) hit exactly this path. Treat 0 like missing for the same reason the + // thought header does: a zero here means "unknown", never "instant". + const reported = + durationMs != null && durationMs > 0 ? Math.floor(durationMs / 1000) : null + const shown = reported ?? (live ? elapsed : null) + const timing = shown != null ? ` ${formatDuration(shown)}` : '' + + if (!done) { + return ( +
+
+ + + {t.chat.processing} + {timing} + {/* The trailing ellipsis means "still running" — an interrupted turn + keeps the "processing" wording but its clock has stopped. */} + {live ? ' ...' : ''} + +
+ {children} +
+ ) + } + + return ( +
+
setExpanded((v) => !v)} + className="flex w-fit cursor-pointer items-center gap-1.5 text-md text-msa-text-3" + > + + {t.chat.processed} + {timing} + + +
+
+
+ {/* Expanded process history gets its own bordered card (design). */} +
+ {children} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/UserBubble.tsx b/webui/frontend/app/components/messages/UserBubble.tsx new file mode 100644 index 000000000..ea103f2c2 --- /dev/null +++ b/webui/frontend/app/components/messages/UserBubble.tsx @@ -0,0 +1,95 @@ +import { FileCard } from '~/components/common/FileCard' +import type { AgentMessage } from '~/lib/agentProvider' +import type { OnOpenFile } from '~/components/messages/types' +import { useT } from '~/lib/i18n' +import { useWorkspaceFileSet } from '~/lib/workspaceFiles' + +/** User message bubble content (right-aligned, preserves line breaks). Any + * files the user attached this turn render as cards above the text; media use + * the workspace raw URL for an inline preview. Non-deleted cards are clickable + * and open the file in the workspace rail. On history replay a file whose + * workspace entry has since been deleted (`exists === false`) falls back to a + * generic card with a "deleted" note and is not clickable. */ +export function UserBubble({ + message, + onOpenFile +}: { + message: AgentMessage + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const files = message.files ?? [] + // Live workspace path set: deletions/renames/creations flip the cards' + // deleted state immediately (fallback: the server-baked `exists` flag). + const fileSet = useWorkspaceFileSet() + return ( +
+ {files.length > 0 && ( +
+ {files.map((f) => { + const deleted = fileSet + ? !fileSet.has(f.path) + : f.exists === false + const card = ( + + ) + if (deleted || !onOpenFile) { + return ( +
+ {card} +
+ ) + } + return ( +
onOpenFile(f.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFile(f.path) + } + }} + className="group/filecard cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-msa-line-2" + > + {card} +
+ ) + })} +
+ )} + {message.segments && message.segments.length > 0 ? ( +
+ {message.segments.map((seg, i) => + seg.type === 'skill' ? ( + + /{seg.name || seg.id} + + ) : ( + {seg.text} + ) + )} +
+ ) : ( + message.content && ( +
+ {message.content} +
+ ) + )} +
+ ) +} diff --git a/webui/frontend/app/components/messages/searchResults.ts b/webui/frontend/app/components/messages/searchResults.ts new file mode 100644 index 000000000..7aa8e27c0 --- /dev/null +++ b/webui/frontend/app/components/messages/searchResults.ts @@ -0,0 +1,52 @@ +/** Parsed web_search result item (exa/unified `{results:[...]}` shape). */ +export interface WebSearchResult { + url: string + title: string + summary: string +} + +/** Parse a web_search tool result into displayable items. Returns [] when the + * payload isn't the unified `{results:[...]}` JSON (e.g. still streaming, or + * a grep/glob text blob). Shared by the chat step card (result count + + * favicons) and the right-rail detail (result cards). */ +export function parseWebSearchResults(result: unknown): WebSearchResult[] { + if (typeof result !== 'string' || !result) return [] + let parsed: unknown + try { + parsed = JSON.parse(result) + } catch { + return [] + } + const results = (parsed as { results?: unknown })?.results + if (!Array.isArray(results)) return [] + return results.map((item) => { + const r = + item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : {} + const s = (v: unknown) => (typeof v === 'string' ? v : '') + return { + url: s(r.url), + title: s(r.title), + summary: s(r.summary) || s(r.content) + } + }) +} + +/** Site hostname for display (strips `www.`), '' when the url is invalid. */ +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return '' + } +} + +/** Public favicon for a result url (Google's s2 service; consumers hide the + * on error so offline/blocked environments degrade gracefully). */ +export function faviconOf(url: string): string { + const host = hostOf(url) + return host + ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32` + : '' +} diff --git a/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx b/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx new file mode 100644 index 000000000..2b60b5a4a --- /dev/null +++ b/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx @@ -0,0 +1,50 @@ +import { FileTypeIcon, formatFileSize } from '~/components/common/FileCard' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import JumpIcon from '~/assets/icons/jump.svg?react' + +/** + * File / artifact step card: standard file card look (icon + name + type/size) + * with an optional preview thumbnail. Clicking opens the workspace rail. + */ +export function ArtifactStepCard({ + step, + onOpenStep +}: { + step: AgentStep + onOpenStep?: OnOpenStep +}) { + const meta = step.meta + const name = String(meta.name ?? '') + const fileType = + typeof meta.file_type === 'string' + ? meta.file_type.toUpperCase() + : (name.split('.').pop()?.toUpperCase() ?? 'FILE') + const byte = typeof meta.byte === 'number' ? meta.byte : undefined + const preview = typeof meta.preview === 'string' ? meta.preview : undefined + + return ( + + ) +} diff --git a/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx b/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx new file mode 100644 index 000000000..1a757d5e9 --- /dev/null +++ b/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx @@ -0,0 +1,208 @@ +import { Typography } from 'antd' +import { useEffect, useState } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import { InlineCode } from '../InlineCode' +import InvokeIcon from '~/assets/icons/invoke.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +type AuthState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** + * Authorization confirm card: renders as a tool-call accordion showing the + * tool name and parameters, with approve/reject buttons when pending. + * + * The `desc` field from the backend is formatted as "tool_name {args_json}". + * We parse it to show structured UI (same accordion style as ToolCallStepCard). + */ +export function AuthConfirmStepCard({ + step, + isLast +}: { + step: AgentStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean +}) { + const { t } = useT() + const metaState = (step.meta.state as AuthState) ?? 'pending' + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive — except while pending (buttons + // must stay visible for the user to decide). + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const desc = String(step.meta.desc ?? '') + const requestId = String(step.meta.request_id ?? '') + const sessionId = String(step.meta.session_id ?? '') + + // Approved and still waiting for the tool's result step to replace this card: + // the header's spinner (in place of the authorize glyph) carries that state, so + // resolving the ask doesn't reflow the card the way a footer row would. + const executing = state === 'approved' && !!requestId + + // Parse "tool_name {args_json}" from desc + const firstBrace = desc.indexOf('{') + const toolName = firstBrace > 0 ? desc.slice(0, firstBrace).trim() : desc + const argsRaw = firstBrace > 0 ? desc.slice(firstBrace) : '' + let argsFormatted = argsRaw + try { + if (argsRaw) argsFormatted = JSON.stringify(JSON.parse(argsRaw), null, 2) + } catch { + // Keep raw if not valid JSON + } + + const resolve = async (action: 'allow_once' | 'allow_always' | 'deny') => { + const next: AuthState = action === 'deny' ? 'rejected' : 'approved' + if (!requestId || !sessionId) { + setState(next) + return + } + setBusy(true) + try { + // allow_always: the SDK also records the tool into the project's + // permission memory (.ms_agent/permission_memory.json), so future calls + // of the same tool skip the ask entirely. + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action + }) + setState(resolved ? next : 'rejected') + } catch { + // Global error toast handles it. + } finally { + setBusy(false) + } + } + + const setState = (next: AuthState) => { + setLocalState(next) + // Also write into the part's meta so the streaming layer can see the + // decision: agentProvider merges the tool's RESULT step into this card + // (approved → replace in place; rejected → drop the errored result step). + step.meta.state = next + } + + return ( +
+ {/* Header */} + + + {/* Body: animated accordion */} +
+
+
+ {/* Arguments */} + {argsFormatted && ( +
+
+ {t.chat.detailArguments} +
+
+                  {argsFormatted}
+                
+
+ )} + + {/* Authorization state: deny / always allow (persisted) / allow once */} + {state === 'pending' && ( +
+ resolve('deny')} + > + {t.chat.authReject} + + resolve('allow_always')} + > + {t.chat.authApproveAlways} + + resolve('allow_once')} + > + {t.chat.authApprove} + +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/steps/StepCardShell.tsx b/webui/frontend/app/components/messages/steps/StepCardShell.tsx new file mode 100644 index 000000000..09ae9fa84 --- /dev/null +++ b/webui/frontend/app/components/messages/steps/StepCardShell.tsx @@ -0,0 +1,196 @@ +import { Typography } from 'antd' +import type { ReactNode } from 'react' +import { FileTypeIcon } from '~/components/common/FileCard' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import { InlineCode } from '../InlineCode' +import JumpIcon from '~/assets/icons/jump.svg?react' +import SearchIcon from '~/assets/icons/search.svg?react' + +/** + * Shared shell for single-line step cards: leading icon + title + trailing + * jump chevron. Clicking opens the workspace rail via `onClick`. When + * `disabled` (e.g. a file whose workspace entry was deleted) it renders as a + * non-interactive card — no chevron, `cursor-not-allowed`, muted title — with + * an optional trailing `note` (e.g. "this file was deleted"). + * + * `nonInteractive` is the milder version of that: nothing to open YET (a search + * still running has no results), so the row keeps its normal look but drops the + * chevron and the hover/click affordance. + * + * Lives in its own module because both the kind dispatcher (StepCard) and + * ToolCallStepCard render one-line rows — the latter for a web search it is + * hosting while it executes. + */ +export function StepCardShell({ + icon, + children, + onClick, + disabled = false, + nonInteractive = false, + note, + noteTone = 'danger', + maxWidthClass = 'max-w-full', + tipText +}: { + icon: ReactNode + children: ReactNode + onClick?: () => void + disabled?: boolean + /** Not openable (yet), but not a dead card either — normal tone, no chevron. */ + nonInteractive?: boolean + note?: ReactNode + /** A refusal is not an error: it reads in the muted tone, like the badge the + * accordion cards use, so only genuine failures are red. */ + noteTone?: 'danger' | 'muted' + maxWidthClass?: string + /** PLAIN-TEXT tooltip for the clipped label. Required because `children` + * carries card chrome (InlineCode chips, favicons) styled for the light card + * surface — rendering that JSX inside antd's dark tooltip produced a pale + * chip on a dark bubble (unreadable). Mirrors ToolCallStepCard's + * `titleText`. */ + tipText?: string +}) { + const noteClass = + noteTone === 'muted' ? 'text-msa-text-3' : 'text-msa-text-danger' + if (disabled || nonInteractive) { + return ( +
+ + {icon} + + + {children} + + {note && ( + {note} + )} +
+ ) + } + return ( + + ) +} + +/** The one-line "doing it now" row: an action that is running (or was just + * approved and is now running). Deliberately NOT openable — there is nothing + * behind it yet. */ +export function InProgressRow({ + label, + detail, + icon +}: { + label: string + detail: string + icon: ReactNode +}) { + return ( + + {label} + {detail ? ( + <> + {' '} + {detail} + + ) : null} + + ) +} + +/** Whether this step kind has an in-progress ROW of its own (below) rather than + * showing progress on its accordion card. Both the kind dispatcher and + * ToolCallStepCard need to know — the latter hosts these kinds' authorization + * asks and must hand back to the row the moment one is approved. */ +export function stepHasInProgressRow(step: AgentStep): boolean { + switch (step.kind) { + case 'search': + return String(step.meta.scope ?? '') !== 'files' + case 'file_read': + case 'file_write': + case 'file_edit': + return true + default: + return false + } +} + +/** In-progress row for the kinds that have one, keeping each action's own + * identity: a search states its query behind the magnifier, a file operation + * states its path behind that file type's glyph — the same glyph its finished + * row uses, so one call keeps one look from ask to result. */ +export function StepInProgressRow({ step }: { step: AgentStep }) { + const { t } = useT() + const meta = step.meta + const path = String(meta.path ?? '') + const fileIcon = + switch (step.kind) { + case 'search': + return ( + } + /> + ) + case 'file_read': + return ( + + ) + case 'file_write': + return ( + + ) + case 'file_edit': + return ( + + ) + default: + return null + } +} diff --git a/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx b/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx new file mode 100644 index 000000000..c4130981a --- /dev/null +++ b/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +type TerminalState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** The code executor answers with a JSON envelope + * (`{success, output, error, return_code, truncated}`), not raw stdout. Unpack it + * so the card can show the OUTPUT the way a terminal does — and so a non-zero + * exit reads as a failure, which the tool call's own status never reports (the + * CALL succeeded; the command inside it didn't). + * + * Anything that isn't that envelope (an interrupt marker, a plain-text result + * from another executor) is passed through as the output verbatim. + */ +function parseExecResult(raw: string): { + output: string + error: string + exitFailed: boolean +} { + if (!raw) return { output: '', error: '', exitFailed: false } + try { + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const env = parsed as Record + if ('output' in env || 'return_code' in env) { + const code = env.return_code + return { + output: String(env.output ?? ''), + error: String(env.error ?? ''), + exitFailed: + env.success === false || + (typeof code === 'number' && code !== 0) + } + } + } + } catch { + // Not JSON: a plain-text result. Fall through. + } + return { output: raw, error: '', exitFailed: false } +} + +/** + * Terminal step card: a collapsible accordion showing a shell command. + * + * When the command requires authorization (`meta.state === 'pending'`), shows + * the same three-way decision as the generic authorization card (reject / always + * run / run once) — a shell ask is rendered by THIS card, because the command is + * what the user is judging. Once resolved (or for normal unrestricted commands), + * the code is just displayed in a scrollable area with max height. + */ +export function TerminalStepCard({ + step, + onOpenStep: _onOpenStep, + isLast +}: { + step: AgentStep + onOpenStep?: OnOpenStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean +}) { + const { t } = useT() + const code = String(step.meta.code ?? '') + const metaState = (step.meta.state as TerminalState | undefined) ?? null + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive — except a pending authorization. + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const requestId = String(step.meta.request_id ?? '') + const sessionId = String(step.meta.session_id ?? '') + + // In progress — either of the two ways a command can be mid-flight: + // - status "running": the SDK announced the call (tool_call_started) and its + // result frame hasn't replaced this card yet; + // - approved here (or re-attached as approved) with the ask still on the card. + // Rendered in the HEADER (spinner in place of the terminal glyph), so + // resolving the ask doesn't reflow the card the way a footer row would. + const executing = + step.meta.status === 'running' || (state === 'approved' && !!requestId) + + // Outcome badges live in the HEADER next to the title (same slot as + // "executing"), so a collapsed card still states how the command ended and + // the body stays purely the command itself. Mirrors ToolCallStepCard: + // - denied: refused here, or replayed from history where the sealed result + // reads "Tool call denied"; + // - failed: a real execution error / interruption, not a refusal. + const errorText = String(step.meta.error ?? '') + const rawResult = String(step.meta.result ?? '') + const exec = parseExecResult(rawResult) + const denied = + state === 'rejected' || + (step.meta.status === 'error' && + /denied/i.test(String(step.meta.error ?? rawResult))) + // A failed run is either a failed CALL (execution error / interruption) or a + // command that exited non-zero. + const failed = (step.meta.status === 'error' || exec.exitFailed) && !denied + const failureText = errorText || exec.error || exec.output || rawResult + // Output gets its own block whenever there is any — except when the error + // block below would print that very same text (an interrupted call stores the + // same marker in both `result` and `error`). The guard only applies when that + // error block actually renders, since `failureText` falls back to the output. + const showOutput = + !!exec.output && + state !== 'pending' && + !denied && + (!failed || exec.output !== failureText) + + const resolve = async (action: 'allow_once' | 'allow_always' | 'deny') => { + const next: TerminalState = action === 'deny' ? 'rejected' : 'approved' + if (!requestId || !sessionId) { + setState(next) + return + } + setBusy(true) + try { + // allow_always also records the tool in the project's permission memory + // (SDK side), so later commands skip the ask entirely. + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action + }) + setState(resolved ? next : 'rejected') + } catch { + // Global api error toast already fired; keep actionable. + } finally { + setBusy(false) + } + } + + const setState = (next: TerminalState) => { + setLocalState(next) + // Write the decision into the part's meta too — the streaming layer drops a + // REFUSED call's errored result step by reading it, so this card keeps + // telling the "rejected" story instead of flipping to "call failed". + step.meta.state = next + } + + return ( +
+ {/* Header: accordion toggle */} + + + {/* Body: animated accordion via grid-template-rows transition */} +
+
+
+
+
+                {code}
+              
+
+ + {/* Command output — the executor's `output`, not its JSON envelope. */} + {showOutput && ( +
+
+ {t.chat.detailResult} +
+
+                  {exec.output}
+                
+
+ )} + + {/* Failure detail: stderr / execution error / interruption marker. + Shown in addition to the output above (a command can print and + then still fail) — unlike a pure tool call, where the two are + mutually exclusive. */} + {failed && !!failureText && ( +
+
+ {t.chat.detailError} +
+
+                  {failureText}
+                
+
+ )} + + {/* Authorization: pending → deny / always run / run once */} + {state === 'pending' && ( +
+ resolve('deny')} + > + {t.chat.authReject} + + resolve('allow_always')} + > + {t.chat.authApproveAlways} + + resolve('allow_once')} + > + {t.chat.authApprove} + +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx b/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx new file mode 100644 index 000000000..2dcb155c6 --- /dev/null +++ b/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx @@ -0,0 +1,273 @@ +import { Typography } from 'antd' +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import { InlineCode } from '../InlineCode' +import { StepInProgressRow, stepHasInProgressRow } from './StepCardShell' +import InvokeIcon from '~/assets/icons/invoke.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +type ToolState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** + * Tool-call step card: a collapsible accordion showing tool name, arguments, + * and result. Supports authorization flow (approve/reject) when the tool + * requires permission (`meta.state === 'pending'`). + */ +export function ToolCallStepCard({ + step, + onOpenStep: _onOpenStep, + isLast, + icon, + title, + titleText +}: { + step: AgentStep + onOpenStep?: OnOpenStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean + /** Header icon override (defaults to the generic invoke icon). */ + icon?: ReactNode + /** Header title override (defaults to the localized "Call {tool name}"). */ + title?: ReactNode + /** Plain-text mirror of `title` for the overflow tooltip (rich nodes like + * InlineCode read badly on the dark tooltip background). */ + titleText?: string +}) { + const { t } = useT() + const meta = step.meta + const name = String(meta.tool ?? meta.name ?? '') + const args = meta.arguments + const argsStr = + args && typeof args === 'object' + ? JSON.stringify(args, null, 2) + : String(args ?? '') + const result = String(meta.result ?? '') + + const metaState = (meta.state as ToolState | undefined) ?? null + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive (mirrors ThoughtsFlow) — except a + // pending authorization, whose buttons must stay visible. + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const requestId = String(meta.request_id ?? '') + const sessionId = String(meta.session_id ?? '') + + // A denied call (live rejection, or history replay where the errored result + // reads "Tool call denied") shows only the arguments — the denial itself is + // conveyed by the header badge, not a useless result block. + const denied = + state === 'rejected' || + (meta.status === 'error' && /denied/i.test(String(meta.error ?? result))) + // A genuinely failed call (execution error / interruption, not a denial): + // badge in the header + the error rendered in the danger tone. + const failed = meta.status === 'error' && !denied + const errorText = String(meta.error ?? result ?? '') + + const resolve = async (action: 'allow_once' | 'allow_always' | 'deny') => { + const next: ToolState = action === 'deny' ? 'rejected' : 'approved' + if (!requestId || !sessionId) { + setState(next) + return + } + setBusy(true) + try { + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action + }) + setState(resolved ? next : 'rejected') + } catch { + // Global error toast handles it. + } finally { + setBusy(false) + } + } + + const setState = (next: ToolState) => { + setLocalState(next) + // Also write the decision into the part's meta: the streaming layer reads it + // to decide what to do with the tool's RESULT step (rejected → the errored + // result is dropped so this card keeps telling the "rejected" story). Mirrors + // AuthConfirmStepCard — required now that asks are hosted by this card + // (backend _AUTH_INLINE_KINDS), where a missed write-back made a REFUSED call + // end up rendered as "call failed". + step.meta.state = next + } + + // In progress — either of the two ways a call can be mid-flight: + // - status "running": announced by tool_call_started, result frame not in yet; + // - approved here with the ask still on the card and no result. + // Rendered in the HEADER (spinner in place of the tool glyph), so resolving + // the ask doesn't reflow the card the way a footer row would. This is also the + // in-progress state for the kinds StepCard routes through this card (browser, + // file grep/glob, memory, skill_load). + const executing = + meta.status === 'running' || (state === 'approved' && !!requestId && !result) + + // A kind with its own in-progress ROW (a web search, a file operation) is only + // using this accordion for its ASK (details + decision buttons). The moment + // it's approved it hands back to that row — rendered from here, not from the + // kind dispatcher, because the approval lives in this component's state and the + // parent won't re-render until the next stream frame (which can be a minute + // away for a slow call). + if (executing && stepHasInProgressRow(step)) { + return + } + + return ( +
+ {/* Header */} + + + {/* Body: animated accordion */} +
+
+
+ {/* Arguments */} + {argsStr && ( +
+
+ {t.chat.detailArguments} +
+
+                  {argsStr}
+                
+
+ )} + + {/* Result: success → normal tone; failure → error tone with the + error text; denied → hidden entirely. */} + {failed ? ( +
+
+ {t.chat.detailError} +
+
+                  {errorText}
+                
+
+ ) : ( + result && + state !== 'pending' && + !denied && ( +
+
+ {t.chat.detailResult} +
+
+                    {result}
+                  
+
+ ) + )} + + {/* Authorization: pending → deny / always run / run once */} + {state === 'pending' && ( +
+ resolve('deny')} + > + {t.chat.authReject} + + resolve('allow_always')} + > + {t.chat.authApproveAlways} + + resolve('allow_once')} + > + {t.chat.authApprove} + +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/turnSplit.ts b/webui/frontend/app/components/messages/turnSplit.ts new file mode 100644 index 000000000..c7998a574 --- /dev/null +++ b/webui/frontend/app/components/messages/turnSplit.ts @@ -0,0 +1,39 @@ +import type { AgentPart } from '~/lib/agentProvider' + +export type TextPart = Extract + +export interface TurnSplit { + /** The turn was stopped by the user (its parts carry the interrupt marker). */ + interrupted: boolean + /** The tool-call loop closed (SDK `loop_end`) → the turn has a final summary + * and its process history folds into a collapsible card. An interrupted turn + * never reaches this state: it keeps the flat "processing" presentation. */ + loopDone: boolean + /** Everything produced BEFORE the summary (thoughts, texts, tool rounds). */ + processParts: AgentPart[] + /** The turn's final answer: its trailing text block, once the loop is done. */ + summary: TextPart | null +} + +/** + * Split one assistant turn into its process history and its final summary. + * + * The summary is the turn's TRAILING text block — the reply the tool-call loop + * ended on (`loop_end` fires right after it). It is only split out once the + * loop is done: while the turn streams, and forever for an interrupted turn, + * every block stays "process" so nothing is hidden behind a header for work + * that never concluded. + */ +export function splitTurn(parts: AgentPart[], streaming: boolean): TurnSplit { + const interrupted = parts.some((p) => p.kind === 'interrupted') + const loopDone = !streaming && !interrupted + const last = parts[parts.length - 1] + const hasSummary = + loopDone && last != null && last.kind === 'text' && !!last.text + return { + interrupted, + loopDone, + processParts: hasSummary ? parts.slice(0, parts.length - 1) : parts, + summary: hasSummary ? (last as TextPart) : null + } +} diff --git a/webui/frontend/app/components/messages/types.ts b/webui/frontend/app/components/messages/types.ts new file mode 100644 index 000000000..08a8bd7ee --- /dev/null +++ b/webui/frontend/app/components/messages/types.ts @@ -0,0 +1,20 @@ +import type { AgentStep, AgentTask, StepKind } from '~/lib/agentProvider' + +/** + * Reference to an artifact/step the host can open in the workspace rail. + * Kept here (was previously in ChatPanel) so message components and the + * artifact panel share one import path. + */ +export interface ArtifactRef { + name: string + meta: Record +} + +/** Callback fired when a step card is clicked (opens the workspace rail). */ +export type OnOpenStep = (step: AgentStep) => void + +/** Callback fired when a user-attached file card is clicked: opens the + * workspace rail and selects the given workspace-relative path. */ +export type OnOpenFile = (path: string) => void + +export type { AgentStep, AgentTask, StepKind } diff --git a/webui/frontend/app/components/models/AddProviderModal.tsx b/webui/frontend/app/components/models/AddProviderModal.tsx new file mode 100644 index 000000000..196fcac22 --- /dev/null +++ b/webui/frontend/app/components/models/AddProviderModal.tsx @@ -0,0 +1,217 @@ +import { App, Form, Input, Modal, Select, Typography } from 'antd' +import { useEffect, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Protocol, Provider } from '~/lib/types' + +interface Props { + open: boolean + /** Existing provider to edit, or null/undefined for create mode. */ + provider?: Provider | null + onClose: () => void + onSaved: (provider: Provider) => void +} + +interface FormValues { + id: string + name: string + base_url: string + protocol: Protocol + api_key?: string +} + +export function AddProviderModal({ open, provider, onClose, onSaved }: Props) { + const { t } = useT() + const { message } = App.useApp() + const [form] = Form.useForm() + const [advancedJson, setAdvancedJson] = useState('{}') + const [submitting, setSubmitting] = useState(false) + + const isEdit = !!provider + + useEffect(() => { + if (!open) return + if (provider) { + form.setFieldsValue({ + id: provider.id, + name: provider.name, + base_url: provider.base_url, + protocol: provider.protocol, + api_key: '' + }) + setAdvancedJson( + JSON.stringify(provider.default_generation_params ?? {}, null, 2) + ) + } else { + form.setFieldsValue({ + id: '', + name: '', + base_url: '', + protocol: 'openai', + api_key: '' + }) + setAdvancedJson('{}') + } + }, [open, provider, form]) + + const submit = async () => { + const v = await form.validateFields() + let advanced: Record = {} + try { + const parsed = JSON.parse(advancedJson || '{}') + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Must be a JSON object') + } + advanced = parsed + } catch (e) { + message.error(`${t.resources.jsonInvalid} (${(e as Error).message})`) + return + } + setSubmitting(true) + try { + let saved: Provider + if (provider) { + // Edit: PATCH the provider. Blank api_key keeps the existing key. + saved = await api.updateProvider(provider.id, { + name: v.name, + base_url: v.base_url, + protocol: v.protocol, + default_generation_params: advanced, + ...(v.api_key ? { api_key: v.api_key } : {}) + }) + } else { + saved = await api.createProvider({ + id: v.id, + name: v.name, + base_url: v.base_url, + protocol: v.protocol, + default_generation_params: advanced + }) + // Custom providers are created with no API key. If the user typed one + // in the optional field, push it as a follow-up update so it lands on + // the mask. + if (v.api_key) { + saved = await api.updateProvider(saved.id, { api_key: v.api_key }) + } + } + onSaved(saved) + } catch { + // API errors surface via the global toast (see root ApiErrorBridge). + } finally { + setSubmitting(false) + } + } + + return ( + + {/* messageVariables on each required item: the labels are JSX nodes (text + + red asterisk, since requiredMark is off), which antd can't interpolate + into its built-in validate messages — it would fall back to the raw + field path. Feeding it the label TEXT keeps antd's own wording. */} +
+ + {t.modelsAdmin.providerName}{' '} + * + + } + name="id" + messageVariables={{ label: t.modelsAdmin.providerName }} + rules={[ + // Two SEPARATE rules on purpose: one rule object carrying both + // `required` and `pattern` would report the pattern's message for an + // empty field too, since a message belongs to the rule, not the check. + { required: true }, + { + pattern: /^[a-z0-9][a-z0-9_-]{0,40}$/, + // The only custom message here: antd's built-in pattern wording + // prints the raw regex at the user. + message: t.modelsAdmin.providerIdHint + } + ]} + extra={ + + {t.modelsAdmin.providerIdHint} + + } + > + + + + {t.modelsAdmin.displayName}{' '} + * + + } + name="name" + messageVariables={{ label: t.modelsAdmin.displayName }} + rules={[{ required: true, max: 80 }]} + > + + + + + + + {t.modelsAdmin.protocol} * + + } + name="protocol" + messageVariables={{ label: t.modelsAdmin.protocol }} + rules={[{ required: true }]} + > + ({ + value: p.id, + label: p.name, + disabled: !p.enabled + }))} + /> + + + {t.modelsAdmin.modelName} * + + } + name="name" + messageVariables={{ label: t.modelsAdmin.modelName }} + rules={[{ required: true, max: 160 }]} + extra={ + + {t.modelsAdmin.modelNameHint} + + } + > + {isEdit ? ( + + ) : ( + ({ value: id }))} + placeholder={t.modelsAdmin.modelNamePlaceholder} + filterOption={(input, option) => + (option?.value ?? '') + .toString() + .toLowerCase() + .includes(input.toLowerCase()) + } + notFoundContent={ + loadingModels ? t.modelsAdmin.modelsLoading : null + } + /> + )} + + + + + +
+ +
+ + {t.modelsAdmin.generationParamsHint} + +
+
+
+ ) +} diff --git a/webui/frontend/app/components/project/McpTabPanel.tsx b/webui/frontend/app/components/project/McpTabPanel.tsx new file mode 100644 index 000000000..ad032f990 --- /dev/null +++ b/webui/frontend/app/components/project/McpTabPanel.tsx @@ -0,0 +1,191 @@ +import { Pagination, Segmented } from 'antd' +import { App } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { dispatchMcpSkillChanged } from '~/lib/events' +import { useT } from '~/lib/i18n' +import type { Mcp, Project, Scope } from '~/lib/types' +import { McpCard } from '~/components/resources/McpCard' +import { McpCustomModal } from '~/components/resources/McpCustomModal' +import { McpJsonView } from '~/components/resources/McpJsonView' +import AddIcon from '~/assets/icons/add.svg?react' + +// ---------- Main component ---------- + +interface Props { + project: Project +} + +type ImportSource = 'custom' | null + +export function McpTabPanel({ project }: Props) { + const { t } = useT() + const { message } = App.useApp() + const projectScope: Scope = `project:${project.id}` + // Scope lives in the URL (?scope=global|project, next to ?tab=) so a reload + // lands back on the same sub-view. Shared with the Skills tab by design. + const [searchParams, setSearchParams] = useSearchParams() + const activeScope: Scope = + searchParams.get('scope') === 'project' ? projectScope : 'global' + const setActiveScope = (v: Scope) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('scope', v === 'global' ? 'global' : 'project') + return next + }, + { replace: true } + ) + const [items, setItems] = useState(null) + const [importing, setImporting] = useState(null) + const [editingMcp, setEditingMcp] = useState(null) + const [viaJson, setViaJson] = useState(false) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 10 + + const refresh = () => + api + .listMcps(activeScope) + .then(setItems) + .catch(() => setItems([])) + + const refreshAndNotify = () => { + refresh() + dispatchMcpSkillChanged() + } + useEffect(() => { + refresh() + setPage(1) + }, [activeScope]) + + // Reset state when project changes (the scope itself is URL-driven; a + // cross-project navigation carries no ?scope, which already means global). + useEffect(() => { + setViaJson(false) + setPage(1) + }, [project.id]) + + const scopeOptions: { value: Scope; label: string }[] = [ + { value: 'global', label: t.resources.globalMcps }, + { value: projectScope, label: t.resources.projectMcps } + ] + + return ( +
+ {/* Toolbar */} +
+ + value={activeScope} + onChange={setActiveScope} + options={scopeOptions} + /> +
+ setViaJson(true)} + className={viaJson ? '!text-msa-text-brand1' : ''} + > + {t.resources.viaJson} + + } + disabled={viaJson} + onClick={() => setImporting('custom')} + > + {t.resources.addMcp} + +
+
+ + {/* Content */} +
+ {viaJson ? ( + setViaJson(false)} + /> + ) : items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((m) => ( + { + await api.updateMcp(m.id, { enabled: v }) + refreshAndNotify() + }} + onReconnect={async () => { + try { + const result = await api.checkMcpHealth(m.id) + if (result.healthy) { + message.success(`${m.name}: ${t.resources.statusOk}`) + } else { + message.error( + `${m.name}: ${result.error || t.resources.statusError}` + ) + } + } catch { + message.error(`${m.name}: ${t.resources.statusError}`) + } + }} + onEdit={() => setEditingMcp(m)} + onRemove={async () => { + await api.deleteMcp(m.id) + refreshAndNotify() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + {/* Modals */} + { + setImporting(null) + setEditingMcp(null) + }} + onSaved={() => { + setImporting(null) + setEditingMcp(null) + refreshAndNotify() + }} + /> +
+ ) +} diff --git a/webui/frontend/app/components/project/MemoryModelConfig.tsx b/webui/frontend/app/components/project/MemoryModelConfig.tsx new file mode 100644 index 000000000..33bcf8da5 --- /dev/null +++ b/webui/frontend/app/components/project/MemoryModelConfig.tsx @@ -0,0 +1,255 @@ +import { InputNumber, Radio, Select } from 'antd' +import { useEffect, useMemo, useState } from 'react' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Model, Provider } from '~/lib/types' + +/** The per-project (or default) memory-model group, API field names. */ +export interface MemoryModelValue { + memory_llm_provider_id: string | null + memory_llm_model: string | null + memory_embed_mode: 'provider' | 'local' + memory_embed_provider_id: string | null + memory_embed_model: string | null + memory_recall_top_k: number | null +} + +export const MEMORY_MODEL_DEFAULTS: MemoryModelValue = { + memory_llm_provider_id: null, + memory_llm_model: null, + memory_embed_mode: 'provider', + memory_embed_provider_id: null, + memory_embed_model: null, + memory_recall_top_k: null +} + +/** Which memory-model fields are invalid for submission. */ +export interface MemoryModelErrors { + /** "Specific provider" chosen for fact extraction but no model picked. */ + llmModel?: boolean + /** "Specific provider" chosen for embedding but no model entered. */ + embedModel?: boolean +} + +/** Validate the group before submit. Both "specific provider" rows require a + * model — "follow conversation" needs nothing, and "local (offline)" carries no + * model field. */ +export function memoryModelErrors(value: MemoryModelValue): MemoryModelErrors { + const errors: MemoryModelErrors = {} + if (value.memory_llm_provider_id && !value.memory_llm_model) + errors.llmModel = true + // Custom embedding = provider mode with a chosen provider; that provider's + // model must be named (there is no "leave blank for default" anymore). + if ( + value.memory_embed_mode === 'provider' && + value.memory_embed_provider_id && + !value.memory_embed_model + ) + errors.embedModel = true + return errors +} + +interface Props { + value: MemoryModelValue + onChange: (patch: Partial) => void + /** Fields to flag as invalid (set by the parent on a failed submit). */ + errors?: MemoryModelErrors +} + +/** + * Vector-memory model rows (extraction model / embedding source / recall + * count) — ONE implementation shared by the settings page (edits the + * new-project defaults) and the project modal (edits the project's own, + * materialized copy). Renders nothing meaningful for the file backend, so + * callers only mount it when the vector backend is selected. + */ +export function MemoryModelConfig({ value, onChange, errors }: Props) { + const { t } = useT() + const [providers, setProviders] = useState([]) + const [models, setModels] = useState([]) + + useEffect(() => { + // Pick among registered providers/models instead of re-entering creds. + api + .listProviders() + .then((p) => setProviders(p.filter((x) => x.enabled))) + .catch(() => setProviders([])) + api + .listModels() + .then(setModels) + .catch(() => setModels([])) + }, []) + + const providerOptions = useMemo( + () => providers.map((p) => ({ value: p.id, label: p.name || p.id })), + [providers] + ) + const modelOptionsFor = (providerId: string | null) => + models + .filter((m) => m.provider_id === providerId) + .map((m) => ({ value: m.name, label: m.display_name || m.name })) + + const llmChoice = value.memory_llm_provider_id ? 'custom' : 'follow' + const embedChoice = + value.memory_embed_mode === 'local' + ? 'local' + : value.memory_embed_provider_id + ? 'custom' + : 'follow' + + return ( +
+ {/* Fact-extraction model — which LLM turns conversation into memory rows. */} +
+
+ + {t.personalization.memoryLlmLabel} + + + onChange( + e.target.value === 'follow' + ? { memory_llm_provider_id: null, memory_llm_model: null } + : { memory_llm_provider_id: providers[0]?.id ?? null } + ) + } + > + + {t.personalization.followConversationModel} + + {t.personalization.specificProvider} + + {llmChoice === 'custom' && ( + <> + onChange({ memory_llm_model: v })} + /> + {errors?.llmModel && ( + + {t.personalization.memoryModelRequired} + + )} + + )} +
+
+ {t.personalization.memoryLlmDesc} +
+
+ + {/* Embedding model — provider (follow/custom) or the local offline model. */} +
+
+ + {t.personalization.memoryEmbedLabel} + + { + const v = e.target.value + if (v === 'follow') + onChange({ + memory_embed_mode: 'provider', + memory_embed_provider_id: null, + memory_embed_model: null + }) + else if (v === 'custom') + onChange({ + memory_embed_mode: 'provider', + memory_embed_provider_id: providers[0]?.id ?? null + }) + else + onChange({ + memory_embed_mode: 'local', + memory_embed_provider_id: null, + memory_embed_model: null + }) + }} + > + + {t.personalization.embedFollowProvider} + + {t.personalization.specificProvider} + {t.personalization.embedLocal} + + {embedChoice === 'custom' && ( + <> + onChange({ memory_embed_model: v })} + /> + {errors?.embedModel && ( + + {t.personalization.memoryEmbedModelRequired} + + )} + + )} +
+
+ {embedChoice === 'local' + ? t.personalization.embedLocalDesc + : t.personalization.memoryEmbedDesc} +
+
+ + {/* Recall count — how many recalled memories are injected per turn. */} +
+ + {t.personalization.memoryRecallLabel} + + + onChange({ memory_recall_top_k: typeof v === 'number' ? v : null }) + } + /> + + {t.personalization.memoryRecallDesc} + +
+
+ ) +} diff --git a/webui/frontend/app/components/project/NewProjectModal.tsx b/webui/frontend/app/components/project/NewProjectModal.tsx new file mode 100644 index 000000000..594c7715a --- /dev/null +++ b/webui/frontend/app/components/project/NewProjectModal.tsx @@ -0,0 +1,452 @@ +import { App, Button, Form, Input, Modal, Radio, Tooltip } from 'antd' +import type { UploadFile } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { api } from '~/lib/api' +import { dispatchWorkspaceChanged } from '~/lib/events' +import { collectDroppedFiles } from '~/lib/dropFiles' +import { useT } from '~/lib/i18n' +import type { MemoryBackend, Project } from '~/lib/types' +import UploadIcon from '~/assets/icons/upload.svg?react' +import { + MEMORY_MODEL_DEFAULTS, + MemoryModelConfig, + memoryModelErrors, + type MemoryModelErrors, + type MemoryModelValue +} from './MemoryModelConfig' +import { MsaSwitch } from '../common/MsaSwitch' +import { MsaButton } from '../common/MsaButton' +import CloseIcon from '~/assets/icons/close.svg?react' +import { FileTypeIcon } from '~/components/common/FileCard' + +interface Props { + open: boolean + project?: Project + onClose: () => void + onCreated: (project: Project) => void + onUpdated?: (project: Project) => void +} + +interface FormValues { + name: string + instructions: string + local_path: string +} + +export function NewProjectModal({ + open, + project, + onClose, + onCreated, + onUpdated +}: Props) { + const { t } = useT() + const { message } = App.useApp() + const [form] = Form.useForm() + const [memoryEnabled, setMemoryEnabled] = useState(true) + const [memoryBackend, setMemoryBackend] = useState('file') + const [memoryModels, setMemoryModels] = useState( + MEMORY_MODEL_DEFAULTS + ) + // Invalid memory-model fields, set on a blocked submit and cleared the moment + // the user edits the group (so the red state tracks the current input). + const [memoryErrors, setMemoryErrors] = useState({}) + // Editing a project whose memory was ever enabled: the backend is frozen. + // Creating always starts unlocked — the lock is applied server-side the + // moment the project is saved with memory on. + const backendLocked = !!project?.memory_backend_locked + const [files, setFiles] = useState([]) + const [submitting, setSubmitting] = useState(false) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + // Callback ref: set webkitdirectory the moment the input appears in the DOM + // (antd Modal renders content lazily via portal, so a one-time useEffect on + // mount misses the timing). + const folderRef = (el: HTMLInputElement | null) => { + folderInputRef.current = el + if (el) { + el.setAttribute('webkitdirectory', '') + el.setAttribute('directory', '') + } + } + + const isEditing = !!project + + useEffect(() => { + if (!open) return + if (isEditing) { + // Edit mode: load existing project data + setMemoryEnabled(project.memory_enabled ?? true) + setMemoryBackend(project.memory_backend ?? 'file') + setMemoryModels({ + memory_llm_provider_id: project.memory_llm_provider_id ?? null, + memory_llm_model: project.memory_llm_model ?? null, + memory_embed_mode: project.memory_embed_mode ?? 'provider', + memory_embed_provider_id: project.memory_embed_provider_id ?? null, + memory_embed_model: project.memory_embed_model ?? null, + memory_recall_top_k: project.memory_recall_top_k ?? null + }) + setFiles([]) + form.setFieldsValue({ + name: project.name, + instructions: '', + local_path: project.local_path ?? '' + }) + // Load existing instruction + api + .getInstruction(`project:${project.id}`) + .then((ins) => { + form.setFieldsValue({ instructions: ins.content ?? '' }) + }) + .catch(() => { + // No instruction saved yet + }) + } else { + // Create mode: reset form with default settings + api.getAgentSettings().then((s) => { + setMemoryEnabled(s.default_memory_enabled) + setMemoryBackend(s.default_memory_backend ?? 'file') + setMemoryModels({ + memory_llm_provider_id: s.memory_llm_provider_id ?? null, + memory_llm_model: s.memory_llm_model ?? null, + memory_embed_mode: s.memory_embed_mode ?? 'provider', + memory_embed_provider_id: s.memory_embed_provider_id ?? null, + memory_embed_model: s.memory_embed_model ?? null, + memory_recall_top_k: s.memory_recall_top_k ?? null + }) + setFiles([]) + form.setFieldsValue({ name: '', instructions: '', local_path: '' }) + }) + } + }, [open, project, form, isEditing]) + + const submit = async () => { + const v = await form.validateFields() + // The memory-model group is not part of the antd Form, so validate it here. + // Only guards the vector backend (the file backend has no such fields). + if (memoryBackend === 'vector') { + const errs = memoryModelErrors(memoryModels) + if (Object.keys(errs).length > 0) { + setMemoryErrors(errs) + message.error(t.personalization.memoryModelIncomplete) + return + } + } + setSubmitting(true) + try { + if (isEditing) { + // Update existing project + const updated = await api.updateProject(project.id, { + name: v.name, + // local_path is deliberately not sent: it is read-only when editing + // and the server rejects a change anyway. + memory_enabled: memoryEnabled, + // Omitted once locked: the server rejects a change, and re-sending + // the current value would be a pointless round trip. + ...(backendLocked ? {} : { memory_backend: memoryBackend }), + // The memory-model group is project-owned; send it whole so the + // server replaces the stored copy. + ...(memoryBackend === 'vector' ? memoryModels : {}) + }) + await api.putInstruction(`project:${project.id}`, v.instructions.trim()) + if (files.length > 0) { + const uploads = files + .filter((f) => f.originFileObj) + .map((f) => + api + .uploadWorkspaceFile( + project.id, + f.originFileObj!, + f.originFileObj!.webkitRelativePath || f.name + ) + .catch(() => {}) + ) + await Promise.all(uploads) + dispatchWorkspaceChanged() + } + form.resetFields() + onUpdated?.(updated) + } else { + // Create new project + const created = await api.createProject({ + name: v.name, + local_path: v.local_path, + memory_enabled: memoryEnabled, + memory_backend: memoryBackend, + ...(memoryBackend === 'vector' ? memoryModels : {}) + }) + if (v.instructions.trim()) { + await api.putInstruction(`project:${created.id}`, v.instructions) + } + if (files.length > 0) { + const uploads = files + .filter((f) => f.originFileObj) + .map((f) => + api + .uploadWorkspaceFile( + created.id, + f.originFileObj!, + f.originFileObj!.webkitRelativePath || f.name + ) + .catch(() => {}) + ) + await Promise.all(uploads) + dispatchWorkspaceChanged() + } + form.resetFields() + onCreated(created) + } + } finally { + setSubmitting(false) + } + } + + return ( + +
+ + + + + + + + + + {/* Unified drop zone: accepts file & folder drops + two pick buttons */} +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={async (e) => { + e.preventDefault() + e.stopPropagation() + // Folders are walked (see lib/dropFiles): `dataTransfer.files` + // would report a dropped directory as one unreadable entry. + const entries = await collectDroppedFiles(e.dataTransfer) + if (entries.length === 0) return + const dropped = entries.map(({ file, path }) => ({ + uid: `${path}-${file.lastModified}-${Math.random()}`, + name: path, + size: file.size, + status: 'done' as const, + originFileObj: file + })) + setFiles((prev) => [...prev, ...(dropped as UploadFile[])]) + }} + > + +

+ {t.newProject.dropZoneTip} +

+
e.stopPropagation()}> + fileInputRef.current?.click()} + > + {t.workspace.uploadFile} + + folderInputRef.current?.click()} + > + {t.workspace.uploadFolder} + +
+
+ {/* Hidden file inputs */} + { + if (!e.target.files) return + const selected = Array.from(e.target.files).map( + (file) => + ({ + uid: `${file.name}-${file.lastModified}-${Math.random()}`, + name: file.webkitRelativePath || file.name, + size: file.size, + status: 'done', + originFileObj: file + }) as UploadFile + ) + setFiles((prev) => [...prev, ...selected]) + e.target.value = '' + }} + /> + { + if (!e.target.files) return + const selected = Array.from(e.target.files).map( + (file) => + ({ + uid: `${file.name}-${file.lastModified}-${Math.random()}`, + name: file.webkitRelativePath || file.name, + size: file.size, + status: 'done', + originFileObj: file + }) as UploadFile + ) + setFiles((prev) => [...prev, ...selected]) + e.target.value = '' + }} + /> + {files.length > 0 && ( +
+ {files.map((f) => ( +
+ + + {f.name} + + +
+ ))} +
+ )} +
+ + {/* Project location. Read-only when editing: the directory IS the + project's identity and holds all of its data, and nothing moves it on + disk, so a changed path would point at a directory without the + project's sessions/workspace/memory (the server rejects it too). */} + + + + {/* Hint styled like the memory-backend one below rather than antd's + `extra`, which renders at the body font size and sits flush against + the field. */} +
+ {isEditing + ? t.newProject.locationLockedHint + : t.newProject.locationHint} +
+ + {/* Memory toggle section */} +
+
+ {t.newProject.memoryTitle} +
+
+ + {t.newProject.memoryDesc} + + +
+ + {/* Storage backend — only meaningful with memory on, and frozen once + the project has ever had memory enabled (it decides the on-disk + layout, so switching later would orphan stored memory). */} +
+ + {t.newProject.memoryBackendLabel} + + {/* Disabled while memory is off (nothing to store, so the choice is + meaningless) and once the backend is frozen. + + Keyed on the LIVE toggle above, never on the SAVED + `project.memory_enabled`: the server only accepts a backend change + while memory has NEVER been enabled + (projects.py::_backend_locked), so gating on the saved value made + the control unreachable exactly when it was still changeable. + Flipping the switch here re-enables these instantly. */} + setMemoryBackend(e.target.value)} + > + {t.newProject.backendFile} + {t.newProject.backendVector} + +
+
+ {backendLocked + ? t.newProject.memoryBackendLockedHint + : t.newProject.memoryBackendHint} +
+ + {/* Vector-only extended config, owned by THIS project (the global + settings only pre-filled it at open). */} + {memoryBackend === 'vector' && ( +
+ { + setMemoryModels((cur) => ({ ...cur, ...patch })) + // Any edit invalidates the last submit's error flags. + setMemoryErrors({}) + }} + /> + {isEditing && + backendLocked && + project?.memory_backend === 'vector' && + (memoryModels.memory_embed_mode !== + (project.memory_embed_mode ?? 'provider') || + memoryModels.memory_embed_provider_id !== + (project.memory_embed_provider_id ?? null) || + memoryModels.memory_embed_model !== + (project.memory_embed_model ?? null)) && ( +
+ {t.newProject.embedChangeWarn} +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/webui/frontend/app/components/project/ProjectOverviewView.css b/webui/frontend/app/components/project/ProjectOverviewView.css new file mode 100644 index 000000000..93dd205c0 --- /dev/null +++ b/webui/frontend/app/components/project/ProjectOverviewView.css @@ -0,0 +1,20 @@ +.pov-tabs-scroll-content .ant-tabs-content-holder { + height: 100%; +} + +.pov-tabs-scroll-content .ant-tabs-content { + height: 100%; + overflow-y: auto; +} + +/* Let a pane's own flex column resolve `h-full` (workspace: fixed info bar + + internally-scrolling file table). A pane whose content overflows still + scrolls via .ant-tabs-content — overflow:visible descendants extend its + scrollable area — so other tabs (recent chats) are unaffected. */ +.pov-tabs-scroll-content .ant-tabs-tabpane { + height: 100%; +} + +.pov-table-no-last-border .ant-table-tbody>tr:last-child>td { + border-bottom: 0; +} \ No newline at end of file diff --git a/webui/frontend/app/components/project/ProjectOverviewView.tsx b/webui/frontend/app/components/project/ProjectOverviewView.tsx new file mode 100644 index 000000000..fc7734e3a --- /dev/null +++ b/webui/frontend/app/components/project/ProjectOverviewView.tsx @@ -0,0 +1,860 @@ +import { BulbOutlined } from '@ant-design/icons' +import './ProjectOverviewView.css' +import { + App, + Button, + ConfigProvider, + Drawer, + Dropdown, + Popconfirm, + Skeleton, + Space, + Table, + Tabs, + Tooltip +} from 'antd' +import type { MenuProps } from 'antd' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router' +import { Composer } from '~/components/common/Composer' +import { IconButton } from '~/components/common/IconButton' +import { MsaButton } from '~/components/common/MsaButton' +import { McpTabPanel } from '~/components/project/McpTabPanel' +import { SkillTabPanel } from '~/components/project/SkillTabPanel' +import { ProjectWidgetRail } from '~/components/project/ProjectWidgetRail' +import { api } from '~/lib/api' +import { dispatchWorkspaceChanged, useOnWorkspaceChanged } from '~/lib/events' +import type { ChatFileRef, MessageSegment } from '~/lib/agentProvider' +import { downloadWorkspaceAll, downloadWorkspacePath } from '~/lib/download' +import { EmptyState } from '~/components/common/EmptyState' +import { SessionRightRail } from '~/components/session/SessionRightRail' +import { RailDrawer } from '~/components/common/RailDrawer' +import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' +import { useT } from '~/lib/i18n' +import type { Project, Session, WorkspaceFile } from '~/lib/types' +import EditIcon from '~/assets/icons/edit.svg?react' +import DetailsIcon from '~/assets/icons/custom-instruction.svg?react' +import RecentChatsIcon from '~/assets/icons/recent-chats.svg?react' +import WorkspaceIcon from '~/assets/icons/workspace.svg?react' +import McpIcon from '~/assets/icons/mcp.svg?react' +import SkillIcon from '~/assets/icons/skill.svg?react' +import JumpIcon from '~/assets/icons/jump.svg?react' +import { FileTypeIcon } from '~/components/common/FileCard' +import ChatsIcon from '~/assets/icons/recent-chats.svg?react' +import MediaIcon from '~/assets/icons/media.svg?react' +import ParamsIcon from '~/assets/icons/params.svg?react' +import TodoIcon from '~/assets/icons/todo.svg?react' +import GlobeIcon from '~/assets/icons/globe.svg?react' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import FolderIcon from '~/assets/icons/folder.svg?react' +import DownloadIcon from '~/assets/icons/download.svg?react' +import CaretDownIcon from '~/assets/icons/chevron-down.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' + +interface Props { + project: Project + sessions: Session[] + onEditProject?: (p: Project) => void +} + +export function ProjectOverviewView({ + project, + sessions, + onEditProject +}: Props) { + const { t } = useT() + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const VALID_TABS = ['recent', 'workspace', 'mcps', 'skills'] as const + const tabFromUrl = searchParams.get('tab') ?? 'recent' + const activeTab = VALID_TABS.includes(tabFromUrl as any) + ? tabFromUrl + : 'recent' + const setActiveTab = (key: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + if (key === 'recent') next.delete('tab') + else next.set('tab', key) + return next + }, + { replace: true } + ) + } + + const handleSubmit = async ( + text: string, + files?: ChatFileRef[], + segments?: MessageSegment[] + ) => { + const session = await api.createSession({ + title: text.slice(0, 60) || 'New chat', + project_id: project.id, + preview: text + }) + // Carry the composer's FULL submission across the navigation. `segments` is + // the ordered text+skill-pill layout: dropping it here downgraded a + // "/skill …" first message to plain text, so the skill never ran for a + // conversation started from this page (it worked from inside a session). + navigate(`/projects/${project.id}/sessions/${session.id}`, { + state: { prefill: text, prefillFiles: files, prefillSegments: segments } + }) + } + + // Right widget rail is side-by-side on >=lg; on smaller screens it collapses + // into a drawer opened from a title-bar button (kept out of the horizontal + // flow so narrow screens never squeeze or hide it silently). + const [detailsDrawer, setDetailsDrawer] = useState(false) + + // Clicking a file in the workspace table opens the SAME rail the chat view + // uses (tree + preview + editor), rather than a second, weaker preview built + // just for this page. `nonce` re-triggers the selection when the same file is + // clicked again after closing the drawer. + const [openFileReq, setOpenFileReq] = useState<{ + path: string + nonce: number + } | null>(null) + const [workspaceDrawer, setWorkspaceDrawer] = useState(false) + const openWorkspaceFile = (path: string) => { + setOpenFileReq((prev) => ({ path, nonce: (prev?.nonce ?? 0) + 1 })) + setWorkspaceDrawer(true) + } + + return ( +
+ {/* Main content */} +
+
+ {/* Project title with edit icon */} +
+

+ {project.name} +

+ {onEditProject && ( + } + size="sm" + variant="filled" + onClick={() => onEditProject(project)} + /> + )} + {/* + } + size="sm" + variant="filled" + onClick={() => setDetailsDrawer(true)} + /> + +
+ + {/* Composer */} + + + {/* Tabs: Recent / Workspace / MCPs / Skills */} + + + {activeTab === 'recent' && ( + + )} + {t.projectDetail.tabRecent} + + ), + children: ( + + ) + }, + { + key: 'workspace', + label: ( + + {activeTab === 'workspace' && ( + + )} + {t.projectDetail.tabWorkspace} + + ), + children: ( + + ) + }, + { + key: 'mcps', + label: ( + + {activeTab === 'mcps' && } + {t.projectDetail.tabMcps} + + ), + children: + }, + { + key: 'skills', + label: ( + + {activeTab === 'skills' && ( + + )} + {t.projectDetail.tabSkills} + + ), + children: + } + ]} + /> + +
+
+ + {/* Right widget rail — large screen only, no divider */} +
+ +
+ + {/* setDetailsDrawer(false)} + placement="right" + size="min(720px, 92vw)" + title={t.projectDetail.detailsPanel} + styles={{ body: { padding: 20 } }} + > + + + + {/* Workspace file preview — the chat view's rail in the shared overlay + shell (RailDrawer). Wider than the chat's version: this page has no + conversation to keep visible behind it, so the tree + preview split + gets more room. */} + setWorkspaceDrawer(false)} + size="min(1100px, 94vw)" + destroyOnHidden + > + setWorkspaceDrawer(false)} + /> + +
+ ) +} + +/* ─── Recent Chats ─────────────────────────────────── */ + +// Topic category -> leading icon for a recent conversation. Keys mirror the +// backend taxonomy (ms_agent/titler.CATEGORIES); an unset/unknown category +// falls back to the generic "general" chat icon. +const CATEGORY_ICON: Record = { + coding: , + writing: , + research: , + planning: , + data: , + creative: , + media: , + general: +} + +function categoryIcon(category?: string): ReactNode { + return CATEGORY_ICON[ + category && category in CATEGORY_ICON ? category : 'general' + ] +} + +function getRelativeTime(dateStr: string, t: any): string { + const now = Date.now() + const then = new Date(dateStr).getTime() + const diff = now - then + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return t.projectDetail.timeJustNow + if (minutes < 60) + return t.projectDetail.timeMinutesAgo.replace('{n}', String(minutes)) + const hours = Math.floor(minutes / 60) + if (hours < 24) + return t.projectDetail.timeHoursAgo.replace('{n}', String(hours)) + const days = Math.floor(hours / 24) + if (days < 30) return t.projectDetail.timeDaysAgo.replace('{n}', String(days)) + return new Date(dateStr).toLocaleDateString() +} + +function RecentChats({ + projectId, + sessions +}: { + projectId: string + sessions: Session[] +}) { + const { t } = useT() + const navigate = useNavigate() + + if (sessions.length === 0) { + return ( + navigate(`/projects/${projectId}/new`)} + > + {t.projectDetail.startChat} + + } + /> + ) + } + + return ( +
+ {sessions.map((s) => ( +
navigate(`/projects/${projectId}/sessions/${s.id}`)} + > + {/* Topic-category icon */} + + {categoryIcon(s.category)} + + {/* Title + preview */} +
+
+ {s.title} +
+ {s.preview && ( +
+ {s.preview} +
+ )} +
+ {/* Relative time / Enter chat */} + + {getRelativeTime(s.updated_at, t)} + + + {t.projectDetail.enterChat} + + +
+ ))} +
+ ) +} + +/* ─── Workspace Panel ──────────────────────────────── */ + +function WorkspacePanel({ + project, + onOpenFile +}: { + project: Project + /** Preview a file in the workspace rail drawer. */ + onOpenFile: (path: string) => void +}) { + // Drives the add-file caret flip (antd Dropdown owns the panel). + const [addMenuOpen, setAddMenuOpen] = useState(false) + const { t } = useT() + const { message } = App.useApp() + const [files, setFiles] = useState(null) + const [currentPath, setCurrentPath] = useState('') + const [downloadingAll, setDownloadingAll] = useState(false) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + // Set webkitdirectory attribute via DOM (React doesn't support it natively) + useEffect(() => { + if (folderInputRef.current) { + folderInputRef.current.setAttribute('webkitdirectory', '') + folderInputRef.current.setAttribute('directory', '') + } + }, []) + + const loadFiles = () => { + api + .listWorkspaceFiles(project.id) + .then(setFiles) + .catch(() => setFiles([])) + } + + useEffect(() => { + loadFiles() + }, [project.id]) + + // Re-fetch when another component uploads/creates files in this workspace. + useOnWorkspaceChanged(loadFiles) + + const handleUpload = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + // Multipart upload preserves raw bytes, so binary files (images, archives, + // …) aren't corrupted by UTF-8 coercion the way `file.text()` would. + const uploads = Array.from(fileList).map((file) => + api + .uploadWorkspaceFile( + project.id, + file, + currentPath + (file.webkitRelativePath || file.name), + { silent: [409] } + ) + .catch(() => {}) + ) + await Promise.all(uploads) + // Broadcast: refreshes this table (own listener), the workspace rail and + // any chat file cards. + dispatchWorkspaceChanged() + } + + // Zip the whole workspace and download it as `.zip`. + const handleDownloadAll = async () => { + if (!files || files.length === 0) return + setDownloadingAll(true) + try { + await downloadWorkspaceAll( + project.id, + files, + `${project.name || 'workspace'}.zip` + ) + } catch { + message.error(t.workspace.downloadFailed) + } finally { + setDownloadingAll(false) + } + } + + // Download a row: a file streams directly, a folder is zipped automatically. + const handleDownload = async (path: string) => { + try { + await downloadWorkspacePath(project.id, path, files ?? []) + } catch { + message.error(t.workspace.downloadFailed) + } + } + + const addMenu: MenuProps = { + items: [ + { + key: 'upload-file', + label: t.workspace.uploadFile, + onClick: () => fileInputRef.current?.click() + }, + { + key: 'upload-folder', + label: t.workspace.uploadFolder, + onClick: () => folderInputRef.current?.click() + } + ] + } + + // Compute visible files at current path level + // Include explicit items + virtual folders derived from nested paths + const visibleFiles = (() => { + const allFiles = files ?? [] + const directItems: WorkspaceFile[] = [] + const virtualFolderNames = new Set() + + for (const f of allFiles) { + if (!f.path.startsWith(currentPath)) continue + const relativePath = f.path.slice(currentPath.length) + if (!relativePath) continue + if (!relativePath.includes('/')) { + // Direct child + directItems.push(f) + } else { + // Nested — extract the first segment as a virtual folder + const folderName = relativePath.split('/')[0] + virtualFolderNames.add(folderName) + } + } + + // Add virtual folders that don't already have an explicit folder entry + const existingNames = new Set( + directItems.map((f) => f.path.slice(currentPath.length)) + ) + for (const name of virtualFolderNames) { + if (!existingNames.has(name)) { + directItems.push({ + project_id: project.id, + path: currentPath + name, + kind: 'folder', + size: 0, + updated_at: new Date().toISOString() + }) + } + } + + // Sort: folders first, then files; within each group sort by name alphabetically + directItems.sort((a, b) => { + const aIsFolder = a.kind === 'folder' ? 0 : 1 + const bIsFolder = b.kind === 'folder' ? 0 : 1 + if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder + const aName = a.path.slice(currentPath.length).toLowerCase() + const bName = b.path.slice(currentPath.length).toLowerCase() + return aName.localeCompare(bName) + }) + + return directItems + })() + + // Find latest updated_at from visible files + const lastEdited = + visibleFiles.length > 0 + ? visibleFiles.reduce((latest, f) => + new Date(f.updated_at) > new Date(latest.updated_at) ? f : latest + ).updated_at + : null + + // Breadcrumb segments + const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [] + + const navigateToSegment = (index: number) => { + if (index < 0) { + setCurrentPath('') + } else { + setCurrentPath(pathSegments.slice(0, index + 1).join('/') + '/') + } + } + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + } + + const formatSize = (bytes: number) => { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}G` + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}M` + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)}K` + return `${bytes}B` + } + + return ( +
+ {/* Hidden file inputs */} + { + handleUpload(e.target.files) + e.target.value = '' + }} + /> + { + handleUpload(e.target.files) + e.target.value = '' + }} + /> + + {files === null ? ( + + {/* Info bar — mirrors the real workspace info bar */} +
+ + +
+ {/* Rows — mirror the file table columns: + icon + name on the left, size · date · actions on the right. */} + {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ + + +
+
+ ))} +
+ ) : files.length > 0 ? ( +
+ {/* Info bar */} +
+ + {t.workspace.lastEdited} + {lastEdited && formatDate(lastEdited)} + + + + + + + +
+ + {/* Breadcrumb */} + {currentPath && ( +
+ + {pathSegments.map((seg, i) => ( + + / + {i < pathSegments.length - 1 ? ( + + ) : ( + {seg} + )} + + ))} +
+ )} + + {/* File table (only this area scrolls; info bar + breadcrumb stay + fixed above it) */} +
+ + ) + }} + columns={[ + { + dataIndex: 'path', + render: (_: string, record: WorkspaceFile) => { + const displayName = record.path.slice(currentPath.length) + return ( + + {record.kind === 'folder' ? ( + + ) : ( + + )} + {record.kind === 'folder' ? ( + + ) : ( + /* Same affordance as a folder row: a file opens its + preview instead of navigating into it. */ + + )} + + ) + } + }, + { + dataIndex: 'size', + width: 100, + render: (size: number) => ( + + {formatSize(size)} + + ) + }, + { + dataIndex: 'updated_at', + width: 140, + render: (date: string) => ( + + {getRelativeTime(date, t)} + + ) + }, + { + key: 'actions', + width: 120, + render: (_: unknown, record: WorkspaceFile) => ( + + + { + if (record.kind === 'folder') { + // Delete all files under this folder + const prefix = record.path + '/' + const children = (files ?? []).filter((f) => + f.path.startsWith(prefix) + ) + await Promise.all( + children.map((f) => + api + .deleteWorkspaceFile(project.id, f.path, { + silent: true + }) + .catch(() => {}) + ) + ) + } + await api + .deleteWorkspaceFile(project.id, record.path) + .catch(() => {}) + dispatchWorkspaceChanged() + }} + okText={t.workspace.delete} + cancelText={t.workspace.cancel} + > + + + + ) + } + ]} + /> + + + ) : ( +
+ {/* Info bar */} +
+ + + + + + +
+
+ +
+
+ )} + + ) +} diff --git a/webui/frontend/app/components/project/ProjectWidgetRail.tsx b/webui/frontend/app/components/project/ProjectWidgetRail.tsx new file mode 100644 index 000000000..e8e191de2 --- /dev/null +++ b/webui/frontend/app/components/project/ProjectWidgetRail.tsx @@ -0,0 +1,28 @@ +import { InstructionsCard } from '~/components/widgets/InstructionsCard' +import { MemoryCard } from '~/components/widgets/MemoryCard' +import { MemoryDocCard } from '~/components/widgets/MemoryDocCard' +import type { Project, Scope } from '~/lib/types' + +interface Props { + project: Project +} + +export function ProjectWidgetRail({ project }: Props) { + const projectScope: Scope = `project:${project.id}` + return ( +
+ + {/* Two shapes of memory, chosen by the project's storage backend: + - file: memory IS one markdown file the agent reads — preview it and + edit the whole document in a drawer; + - vector: individually embedded memories, written by the agent's own + fact extraction — a read-only list whose only action is + removing one the agent got wrong. */} + {project.memory_backend === 'vector' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/webui/frontend/app/components/project/SkillTabPanel.tsx b/webui/frontend/app/components/project/SkillTabPanel.tsx new file mode 100644 index 000000000..7ba62ae45 --- /dev/null +++ b/webui/frontend/app/components/project/SkillTabPanel.tsx @@ -0,0 +1,152 @@ +import { Pagination, Segmented } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { dispatchMcpSkillChanged } from '~/lib/events' +import { useT } from '~/lib/i18n' +import type { Project, Scope, Skill } from '~/lib/types' +import { SkillCard } from '~/components/resources/SkillCard' +import { SkillsFromLocalModal } from '~/components/resources/SkillsFromLocalModal' +import { SkillDetailDrawer } from '~/components/resources/SkillDetailDrawer' +import AddIcon from '~/assets/icons/add.svg?react' + +// ---------- Main component ---------- + +interface Props { + project: Project +} + +export function SkillTabPanel({ project }: Props) { + const { t } = useT() + const projectScope: Scope = `project:${project.id}` + // Scope lives in the URL (?scope=global|project, next to ?tab=) so a reload + // lands back on the same sub-view. Shared with the MCPs tab by design. + const [searchParams, setSearchParams] = useSearchParams() + const activeScope: Scope = + searchParams.get('scope') === 'project' ? projectScope : 'global' + const setActiveScope = (v: Scope) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('scope', v === 'global' ? 'global' : 'project') + return next + }, + { replace: true } + ) + const [items, setItems] = useState(null) + const [showLocal, setShowLocal] = useState(false) + const [detailSkill, setDetailSkill] = useState(null) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 10 + + const refresh = () => + api + .listSkills(activeScope) + .then(setItems) + .catch(() => setItems([])) + + const refreshAndNotify = () => { + refresh() + dispatchMcpSkillChanged() + } + useEffect(() => { + refresh() + setPage(1) + }, [activeScope]) + + // Reset state when project changes (the scope itself is URL-driven). + useEffect(() => { + setPage(1) + }, [project.id]) + + const scopeOptions: { value: Scope; label: string }[] = [ + { value: 'global', label: t.resources.globalSkills }, + { value: projectScope, label: t.resources.projectSkills } + ] + + return ( +
+ {/* Toolbar */} +
+ + value={activeScope} + onChange={setActiveScope} + options={scopeOptions} + /> + } + onClick={() => setShowLocal(true)} + > + {t.resources.addSkill} + +
+ + {/* Content */} +
+ {items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((s) => ( + { + await api.updateSkill(s.id, { enabled: v }) + refreshAndNotify() + }} + onView={() => setDetailSkill(s)} + onRemove={async () => { + await api.deleteSkill(s.id) + refreshAndNotify() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + {/* Upload modal */} + setShowLocal(false)} + onImported={() => { + setShowLocal(false) + refreshAndNotify() + }} + /> + + {/* Detail drawer */} + setDetailSkill(null)} + onSkillChange={setDetailSkill} + /> +
+ ) +} diff --git a/webui/frontend/app/components/resources/McpCard.tsx b/webui/frontend/app/components/resources/McpCard.tsx new file mode 100644 index 000000000..343cf21ae --- /dev/null +++ b/webui/frontend/app/components/resources/McpCard.tsx @@ -0,0 +1,110 @@ +import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { useState } from 'react' +import { MsaSwitch } from '~/components/common/MsaSwitch' +import { useT } from '~/lib/i18n' +import type { Mcp } from '~/lib/types' +import MoreIcon from '~/assets/icons/more.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' + +interface McpCardProps { + mcp: Mcp + onToggle: (v: boolean) => void + onReconnect?: () => void + onEdit?: () => void + onRemove: () => void +} + +export function McpCard({ + mcp, + onToggle, + onReconnect, + onEdit, + onRemove +}: McpCardProps) { + const { t } = useT() + const [confirmOpen, setConfirmOpen] = useState(false) + const [testing, setTesting] = useState(false) + + const menu: MenuProps = { + onClick: (e) => e.domEvent.stopPropagation(), + items: [ + ...(onEdit + ? [{ key: 'edit', label: t.resources.edit, onClick: onEdit }] + : []), + { + key: 'remove', + label: t.resources.remove, + danger: true, + onClick: () => setConfirmOpen(true) + } + ] + } + + return ( +
onToggle(!mcp.enabled)} + > +
+ + {mcp.name} + + e.stopPropagation()}> + + +
+
+ + {mcp.description || t.resources.noDescription} + +
e.stopPropagation()} + > + {onReconnect && mcp.transport !== 'stdio' && ( + +
+
+
+ ) +} diff --git a/webui/frontend/app/components/resources/McpCustomModal.tsx b/webui/frontend/app/components/resources/McpCustomModal.tsx new file mode 100644 index 000000000..69bc2375c --- /dev/null +++ b/webui/frontend/app/components/resources/McpCustomModal.tsx @@ -0,0 +1,225 @@ +import { App, Form, Input, Modal, Radio, Segmented } from 'antd' +import { useEffect, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, McpTransport, Scope } from '~/lib/types' +import { fromMcpServers, toMcpServers } from './mcpJson' + +interface Props { + open: boolean + scope: Scope + scopeBadge?: string + /** When provided, modal enters edit mode with pre-filled values */ + editingMcp?: Mcp | null + onClose: () => void + onSaved: () => void +} + +const TEMPLATE = `{ + "mcpServers": { + "stdio-server-example": { + "command": "npx", + "args": ["-y", "mcp-server-example"] + }, + "sse-server-example": { + "type": "streamable_http", + "url": "https://example.com/mcp" + } + } +} +` + +type TabMode = 'form' | 'json' + +export function McpCustomModal({ + open, + scope, + scopeBadge, + editingMcp, + onClose, + onSaved +}: Props) { + const { t } = useT() + const { message } = App.useApp() + const isEdit = !!editingMcp + const [tab, setTab] = useState('form') + const [text, setText] = useState('') + const [submitting, setSubmitting] = useState(false) + const [form] = Form.useForm<{ + name: string + transport: McpTransport + endpoint: string + description: string + }>() + + useEffect(() => { + if (open) { + setTab('form') + if (editingMcp) { + // Both tabs are views of the SAME server, so the JSON one is seeded with + // the server's current config — an empty editor would look like there is + // nothing to edit, and saving it would wipe the config. + setText(JSON.stringify(toMcpServers([editingMcp]), null, 2)) + form.setFieldsValue({ + name: editingMcp.name, + transport: editingMcp.transport, + endpoint: editingMcp.endpoint, + description: editingMcp.description + }) + } else { + setText('') + form.resetFields() + } + } + }, [open, form, editingMcp]) + + const submitForm = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + if (isEdit) { + await api.updateMcp(editingMcp!.id, values) + } else { + await api.createMcp({ ...values, enabled: true, scope }) + } + onSaved() + } finally { + setSubmitting(false) + } + } + + const submitJson = async () => { + let parsed + try { + parsed = fromMcpServers(text) + } catch (e) { + message.error(`${t.mcpImport.customInvalid} (${(e as Error).message})`) + return + } + if (parsed.length === 0) { + onClose() + return + } + // Editing targets ONE existing server: creating from here would leave the + // edited server untouched and silently add a duplicate beside it. + if (isEdit && parsed.length > 1) { + message.error(t.mcpImport.editSingleOnly) + return + } + setSubmitting(true) + try { + if (isEdit) { + await api.updateMcp(editingMcp!.id, parsed[0]) + } else { + for (const m of parsed) { + await api.createMcp({ ...m, scope }) + } + } + onSaved() + } finally { + setSubmitting(false) + } + } + + const transport = Form.useWatch('transport', form) ?? 'sse' + + return ( + + + {isEdit ? t.mcpImport.editTitle : t.mcpImport.customTitle} + + {scopeBadge && ( + + {scopeBadge} + + )} + + } + okText={isEdit ? t.resources.save : t.mcpImport.customConfirm} + cancelText={t.resources.cancel} + onOk={tab === 'form' ? submitForm : submitJson} + okButtonProps={{ loading: submitting }} + destroyOnHidden + width={620} + > + + value={tab} + onChange={setTab} + options={[ + // Neutral labels: these switch the VIEW (form vs raw JSON), and the + // modal title already says whether we are adding or editing. + { value: 'form', label: t.mcpImport.formTab }, + { value: 'json', label: t.mcpImport.jsonTab } + ]} + className="mb-4" + /> + + {tab === 'form' ? ( +
+ + + + + + SSE + StreamableHTTP + STDIO + + + + {transport === 'stdio' ? ( + + ) : ( + + )} + + + + + + ) : ( +
+ {/* The template hint is passed to monaco (not drawn as an overlay), so + it lands on the same baseline/indent as the caret. */} + +
+ )} +
+ ) +} diff --git a/webui/frontend/app/components/resources/McpJsonView.tsx b/webui/frontend/app/components/resources/McpJsonView.tsx new file mode 100644 index 000000000..cf6f5c83f --- /dev/null +++ b/webui/frontend/app/components/resources/McpJsonView.tsx @@ -0,0 +1,87 @@ +import { App, Button } from 'antd' +import { useEffect, useMemo, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, Scope } from '~/lib/types' +import { fromMcpServers, toMcpServers } from './mcpJson' + +interface McpJsonViewProps { + scope: Scope + items: Mcp[] + onSaved: () => void + onCancel: () => void +} + +export function McpJsonView({ + scope, + items, + onSaved, + onCancel +}: McpJsonViewProps) { + const { t } = useT() + const { message } = App.useApp() + const original = useMemo( + () => JSON.stringify(toMcpServers(items), null, 2), + [items] + ) + const [text, setText] = useState(original) + const [saving, setSaving] = useState(false) + + useEffect(() => { + setText(original) + }, [original]) + + const dirty = text !== original + + const save = async () => { + let parsed + try { + parsed = fromMcpServers(text) + } catch (e) { + message.error(`${t.resources.jsonInvalid} (${(e as Error).message})`) + return + } + setSaving(true) + try { + // ONE atomic call. Deleting every server and re-creating them from the + // document (what this did before) meant a rename left the old server + // behind whenever its delete was lost to a concurrent one, and a single + // rejected entry wiped the whole scope, since the deletes had landed. + await api.replaceMcps( + scope, + parsed.map((m) => ({ ...m, scope })) + ) + onSaved() + } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+
+ + + +
+
+ ) +} diff --git a/webui/frontend/app/components/resources/McpsPanel.tsx b/webui/frontend/app/components/resources/McpsPanel.tsx new file mode 100644 index 000000000..a508e0c82 --- /dev/null +++ b/webui/frontend/app/components/resources/McpsPanel.tsx @@ -0,0 +1,135 @@ +import { App, Pagination } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, Scope } from '~/lib/types' +import { McpCard } from './McpCard' +import { McpCustomModal } from './McpCustomModal' +import { McpJsonView } from './McpJsonView' + +type ImportSource = 'custom' | null + +interface McpsPanelProps { + viaJson?: boolean + onViaJsonChange?: (v: boolean) => void + importing?: ImportSource + onImportingChange?: (v: ImportSource) => void +} + +export function McpsPanel({ + viaJson = false, + onViaJsonChange, + importing: importingProp, + onImportingChange +}: McpsPanelProps) { + const { t } = useT() + const { message } = App.useApp() + const [searchParams] = useSearchParams() + // Scope lives in the URL (?scope=), so it is derived, not mirrored in state. + const activeScope: Scope = + (searchParams.get('scope') as Scope | null) ?? 'global' + const [items, setItems] = useState(null) + const [editingMcp, setEditingMcp] = useState(null) + const [importingInternal, setImportingInternal] = useState(null) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 12 + + const importing = importingProp ?? importingInternal + const setImporting = onImportingChange ?? setImportingInternal + + const refresh = () => api.listMcps(activeScope).then(setItems) + useEffect(() => { + setItems(null) + setPage(1) + refresh() + }, [activeScope]) + + const scopeBadge = + activeScope === 'global' + ? t.mcpImport.hubGlobalBadge + : t.mcpImport.hubProjectBadge + + return ( +
+
+ {viaJson ? ( + onViaJsonChange?.(false)} + /> + ) : items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((m) => ( + { + await api.updateMcp(m.id, { enabled: v }) + refresh() + }} + onReconnect={async () => { + try { + const result = await api.checkMcpHealth(m.id) + if (result.healthy) { + message.success(`${m.name}: ${t.resources.statusOk}`) + } else { + message.error(`${m.name}: ${result.error || t.resources.statusError}`) + } + } catch { + message.error(`${m.name}: ${t.resources.statusError}`) + } + }} + onEdit={() => setEditingMcp(m)} + onRemove={async () => { + await api.deleteMcp(m.id) + refresh() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + { + setImporting(null) + setEditingMcp(null) + }} + onSaved={() => { + setImporting(null) + setEditingMcp(null) + refresh() + }} + /> +
+ ) +} diff --git a/webui/frontend/app/components/resources/SkillCard.tsx b/webui/frontend/app/components/resources/SkillCard.tsx new file mode 100644 index 000000000..b13e95872 --- /dev/null +++ b/webui/frontend/app/components/resources/SkillCard.tsx @@ -0,0 +1,91 @@ +import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { useMemo, useState } from 'react' +import { MsaSwitch } from '~/components/common/MsaSwitch' +import { useT } from '~/lib/i18n' +import type { Skill } from '~/lib/types' +import MoreIcon from '~/assets/icons/more.svg?react' + +interface SkillCardProps { + skill: Skill + onToggle: (v: boolean) => void + onView?: () => void + onRemove: () => void +} + +export function SkillCard({ + skill, + onToggle, + onView, + onRemove +}: SkillCardProps) { + const { t } = useT() + const [confirmOpen, setConfirmOpen] = useState(false) + + const oneLiner = useMemo(() => { + const firstLine = (skill.content || '') + .split('\n') + .map((s) => s.trim()) + .find((s) => s && !s.startsWith('#')) + return firstLine || '' + }, [skill.content]) + + const menu: MenuProps = { + onClick: (e) => e.domEvent.stopPropagation(), + items: [ + ...(onView + ? [{ key: 'view', label: t.resources.tryIt, onClick: onView }] + : []), + { + key: 'remove', + label: t.resources.remove, + danger: true, + onClick: () => setConfirmOpen(true) + } + ] + } + + return ( +
onToggle(!skill.enabled)} + > +
+ + {skill.name} + + e.stopPropagation()}> + + +
+
+ + {oneLiner || t.resources.noDescription} + + { + setConfirmOpen(false) + onRemove() + }} + onCancel={() => setConfirmOpen(false)} + okText={t.resources.remove} + okButtonProps={{ danger: true }} + > + + +
+
+ ) +} diff --git a/webui/frontend/app/components/resources/SkillDetailDrawer.tsx b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx new file mode 100644 index 000000000..a792939b2 --- /dev/null +++ b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx @@ -0,0 +1,229 @@ +import { Drawer, Segmented, Select, Tooltip } from 'antd' +import type { TreeDataNode } from 'antd' +import { useEffect, useMemo, useRef, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { FolderTree } from '~/components/common/FolderTree' +import { Markdown } from '~/components/common/Markdown' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Skill } from '~/lib/types' +import ViewIcon from '~/assets/icons/view.svg?react' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import { languageFor } from '~/lib/editorLanguage' + +interface Props { + open: boolean + skill: Skill | null + /** Sibling skills shown in the top-left switcher dropdown. */ + allSkills: Skill[] + onClose: () => void + onSkillChange: (skill: Skill) => void +} + +type ViewMode = 'preview' | 'code' + + +/** Build a FolderTree data set from the backend's flat relative-path list. + * Keys follow the FolderTree convention (`dir:` / `file:`) so the + * tree infers icons itself. Directories first, then files, both sorted. */ +function buildTree(paths: string[]): TreeDataNode[] { + interface DirNode { + dirs: Map + files: string[] // full relative paths + } + const root: DirNode = { dirs: new Map(), files: [] } + for (const p of paths) { + const parts = p.split('/') + let cur = root + for (const part of parts.slice(0, -1)) { + let next = cur.dirs.get(part) + if (!next) { + next = { dirs: new Map(), files: [] } + cur.dirs.set(part, next) + } + cur = next + } + cur.files.push(p) + } + const toNodes = (node: DirNode, prefix: string): TreeDataNode[] => { + const dirs = [...node.dirs.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, child]) => { + const full = prefix ? `${prefix}/${name}` : name + return { + key: `dir:${full}`, + title: name, + children: toNodes(child, full) + } as TreeDataNode + }) + const files = [...node.files] + .sort((a, b) => a.localeCompare(b)) + .map( + (p) => + ({ + key: `file:${p}`, + title: p.split('/').pop() ?? p, + isLeaf: true + }) as TreeDataNode + ) + return [...dirs, ...files] + } + return toNodes(root, '') +} + +export function SkillDetailDrawer({ + open, + skill, + allSkills, + onClose, + onSkillChange +}: Props) { + const { t } = useT() + const [selected, setSelected] = useState('SKILL.md') + const [viewMode, setViewMode] = useState('preview') + // Real relative paths of the skill's directory (from the backend). + const [files, setFiles] = useState(['SKILL.md']) + // Fetched file bodies keyed by relative path; null ⇒ binary file. + const [bodies, setBodies] = useState>({}) + const fetchSeq = useRef(0) + + useEffect(() => { + if (!open || !skill) return + setSelected('SKILL.md') + setViewMode('preview') + // Seed SKILL.md from the already-loaded skill body for an instant first + // paint; the file list arrives async. + setBodies({ 'SKILL.md': skill.content ?? '' }) + setFiles(['SKILL.md']) + const seq = ++fetchSeq.current + api + .listSkillFiles(skill.id) + .then((rows) => { + if (seq !== fetchSeq.current) return + if (rows.length) setFiles(rows.map((r) => r.path)) + }) + .catch(() => {}) + }, [open, skill]) + + // Lazy-load the selected file's content (cached per path). + useEffect(() => { + if (!open || !skill) return + if (selected in bodies) return + const seq = fetchSeq.current + api + .getSkillFile(skill.id, selected) + .then((f) => { + if (seq !== fetchSeq.current) return + setBodies((prev) => ({ ...prev, [f.path]: f.content })) + }) + .catch(() => {}) + }, [open, skill, selected, bodies]) + + const treeData = useMemo(() => buildTree(files), [files]) + const body = skill ? bodies[selected] : undefined + + const language = languageFor(selected) + const isMarkdown = language === 'markdown' + const isBinary = body === null + + return ( + + {!skill ? null : ( +
+