diff --git a/samples/mcp/a2ui-in-mcpapps/README.md b/samples/mcp/a2ui-in-mcpapps/README.md index 03eff3ff2..a78f22122 100644 --- a/samples/mcp/a2ui-in-mcpapps/README.md +++ b/samples/mcp/a2ui-in-mcpapps/README.md @@ -6,7 +6,8 @@ This sample demonstrates a Model Context Protocol (MCP) Application Host that is - **`client/`**: The host container application (Angular). It hosts the outer safe iframe. - **`server/`**: The MCP Server (Python/uv) that provides the micro-app resources and tools. -- **`server/apps/src/`**: The source source code for the server-hosted isolated micro-app. +- **`server/apps/src/`**: Source code for the **Basic** isolated micro-app. +- **`server/apps/editor/`**: Source code for the **Editor** isolated micro-app. ## Communication Flow @@ -50,14 +51,31 @@ sequenceDiagram ## Prerequisites -- [Node.js](https://nodejs.org/) (runs the client and build scripts) -- [Python 3.10+](https://www.python.org/) with `uv` (runs the MCP server) +- [Node.js](https://nodejs.org/) (LTS recommended) +- [Python 3.10+](https://www.python.org/) with `uv` + +### ⚠️ IMPORTANT: Pre-build Core Dependencies + +The sample apps link to local versions of the A2UI SDK. You **must build the core libraries** before attempting to run `npm install` inside any sample subdirectories. + +Run the following from the repository root: + +```bash +# 1. Web Core +cd renderers/web_core && npm install && npm run build && cd ../.. + +# 2. Markdown Utilities +cd renderers/markdown/markdown-it && npm install && npm run build && cd ../../.. + +# 3. Angular Renderer SDK +cd renderers/angular && npm install && npm run build && cd ../.. +``` --- ## Build & Regeneration -This sample relies on some generated bundle artifacts. Some are committed for convenience, while others are ignored and must be built. +This sample relies on generated bundle artifacts. ### 1. Build Client Sandbox Bridge @@ -71,11 +89,21 @@ npm run build:sandbox _(Generates `client/public/sandbox_iframe/sandbox.{js,html}`)_ -### 2. Rebuild the Server Hosted App (Optional) +### 2. Rebuild Micro-Apps (Optional) + +The server serves single-file HTML artifacts located in `server/apps/public/`. Choose the app you want to build: + +#### Option A: The Editor App + +```bash +cd server/apps/editor +npm install +npm run build:all +``` -The server serves a bundled `app.html` artifact located in `server/apps/public/app.html`. If you modify the source code in `server/apps/src/`, you must regenerate this list: +_(Generates `server/apps/public/editor.html`)_ -Run this in the `server/apps/src/` directory: +#### Option B: The Basic App ```bash cd server/apps/src @@ -83,7 +111,7 @@ npm install npm run build:all ``` -_(Runs Angular compilation and triggers `node inline.js` to single-file inline it into `server/apps/public/app.html`)_ +_(Generates `server/apps/public/app.html`)_ --- diff --git a/samples/mcp/a2ui-in-mcpapps/client/angular.json b/samples/mcp/a2ui-in-mcpapps/client/angular.json index 35ab07c3c..5dda4a17b 100644 --- a/samples/mcp/a2ui-in-mcpapps/client/angular.json +++ b/samples/mcp/a2ui-in-mcpapps/client/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": "322f21ca-3fe1-45b7-b271-10523235cd8e" }, "newProjectRoot": "projects", "projects": { diff --git a/samples/mcp/a2ui-in-mcpapps/client/src/app/app.html b/samples/mcp/a2ui-in-mcpapps/client/src/app/app.html index f93e8afd5..c8770f4ca 100644 --- a/samples/mcp/a2ui-in-mcpapps/client/src/app/app.html +++ b/samples/mcp/a2ui-in-mcpapps/client/src/app/app.html @@ -18,8 +18,23 @@

Simple MCP Apps Host

Status: {{ status() }}

+
+ + +
diff --git a/samples/mcp/a2ui-in-mcpapps/client/src/app/app.ts b/samples/mcp/a2ui-in-mcpapps/client/src/app/app.ts index 1d75921b4..479d647eb 100644 --- a/samples/mcp/a2ui-in-mcpapps/client/src/app/app.ts +++ b/samples/mcp/a2ui-in-mcpapps/client/src/app/app.ts @@ -32,9 +32,17 @@ export class App implements AfterViewInit { private messageListenerAdded = false; protected readonly mcpAppHtmlUrl = signal(null); protected readonly isAppLoading = signal(false); + protected readonly selectedApp = signal<'editor' | 'basic'>('editor'); private mcpClient: Client | null = null; + private readonly allowedTools = new Set([ + 'fetch_counter_a2ui', + 'increase_counter', + 'smart_editor_get_controls', + 'smart_editor_apply', + ]); + ngAfterViewInit() { if (this.messageListenerAdded) return; this.messageListenerAdded = true; @@ -75,40 +83,50 @@ export class App implements AfterViewInit { window.location.origin, ); } - } else if (data?.method === 'ui/fetch_counter_a2ui') { - if (data.id && target && this.mcpClient) { - this.mcpClient - .callTool({ - name: 'fetch_counter_a2ui', - arguments: {}, - }) - .then(result => { - target.postMessage( - { - jsonrpc: '2.0', - id: data.id, - result: result.content, - }, - window.location.origin, - ); - }) - .catch(error => { - target.postMessage( - { - jsonrpc: '2.0', - id: data.id, - error: {message: error.message}, + } else if (data?.method === 'ui/initialize') { + if (data.id && target) { + target.postMessage( + { + jsonrpc: '2.0', + id: data.id, + result: { + hostCapabilities: { + displayModes: ['inline'], }, - window.location.origin, - ); - }); + }, + }, + window.location.origin, + ); + } + } else if (data?.method === 'ui/resize') { + const height = data.params?.height; + if (typeof height === 'number') { + iframe.style.height = `${height}px`; + } + } else if (data?.method?.startsWith('ui/')) { + // Generic tool relay for unknown verbs + const toolName = data.method.replace('ui/', ''); + + if (!this.allowedTools.has(toolName)) { + console.warn(`[Host] Blocked unauthorized tool call: ${toolName}`); + if (data.id && target) { + target.postMessage( + { + jsonrpc: '2.0', + id: data.id, + error: {message: `Tool '${toolName}' is not whitelisted.`}, + }, + window.location.origin, + ); + } + return; } - } else if (data?.method === 'ui/increase_counter') { + if (data.id && target && this.mcpClient) { this.mcpClient .callTool({ - name: 'increase_counter', - arguments: {}, + name: toolName, + arguments: data.params || {}, }) .then(result => { target.postMessage( @@ -131,30 +149,18 @@ export class App implements AfterViewInit { ); }); } - } else if (data?.method === 'ui/initialize') { - if (data.id && target) { - target.postMessage( - { - jsonrpc: '2.0', - id: data.id, - result: { - hostCapabilities: { - displayModes: ['inline'], - }, - }, - }, - window.location.origin, - ); - } - } else if (data?.method === 'ui/resize') { - const height = data.params?.height; - if (typeof height === 'number') { - iframe.style.height = `${height}px`; - } } }); } + onAppChange(value: string) { + if (value === 'editor' || value === 'basic') { + this.selectedApp.set(value); + } else { + console.error(`[Host] Invalid app selected: ${value}`); + } + } + async connectAndLoadApp() { this.status.set('Connecting to MCP Server...'); this.isAppLoading.set(true); @@ -164,7 +170,7 @@ export class App implements AfterViewInit { const transport = new SSEClientTransport(new URL('http://127.0.0.1:8000/sse')); const client = new Client( { - name: 'basic-host', + name: 'editor-host', version: '1.0.0', }, { @@ -176,10 +182,12 @@ export class App implements AfterViewInit { await client.connect(transport); this.mcpClient = client; - this.status.set('Calling get_basic_app tool...'); + this.status.set('Calling MCP App tool...'); + const toolName = this.selectedApp() === 'editor' ? 'get_editor_app' : 'get_basic_app'; + // 2. Call the tool to get the app const result = await client.callTool({ - name: 'get_basic_app', + name: toolName, arguments: {}, }); diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/README.md b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/README.md new file mode 100644 index 000000000..0ae434789 --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/README.md @@ -0,0 +1,104 @@ +# A2UI-Powered Document Editor Micro-App + +This directory contains the source code for the generative document editor micro-app based on Angular and Editor.js. + +It is built as a standalone static bundle and served by the MCP server as an isolated resource to be rendered securely within the host container. + +--- + +## Prerequisites + +### 1. Node.js + +Ensure you have Node.js installed (LTS v20 or v22 is recommended). + +If Node.js is missing from your PATH, consider installing it via NVM (Node Version Manager): + +```bash +# Download and install NVM +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + +# Refresh terminal +source ~/.bashrc + +# Install LTS Node.js +nvm install --lts +``` + +### 2. Build Core A2UI Libraries + +This application has file-based dependencies on the core A2UI packages which reside elsewhere in this repository. You **must build these packages first** in specific order before this application's `npm install` will succeed. + +Run the following commands from the **root of the repository**: + +```bash +# 1. Build Web Core +cd renderers/web_core +npm install +npm run build + +# 2. Build Markdown Utilities +cd renderers/markdown/markdown-it +npm install +npm run build + +# 3. Build Angular Renderer +cd renderers/angular +npm install +npm run build + +cd ../../ +``` + +--- + +## Local Execution Workflow + +To execute and run this application locally, you need to build the application bundle, ensure the host environment assets are ready, and then boot the services. + +### Step 1: Build the Editor App Bundle + +From inside this directory (`samples/mcp/a2ui-in-mcpapps/server/apps/editor`): + +```bash +# Install local package dependencies +npm install --legacy-peer-deps + +# Build Angular project AND generate the single self-contained HTML file +npm run build:all +``` + +_This outputs the final static artifact into `../public/editor.html` which the Python server reads._ + +### Step 2: Build the Client Host Bridge (Required Once) + +Navigate to the client host directory and build its security-sandbox bridge: + +```bash +cd ../../../client +npm install +npm run build:sandbox +``` + +### Step 3: Run the Full Local Environment + +Open two terminals to start the stack: + +#### Terminal A: Run MCP Server (Python) + +```bash +cd samples/mcp/a2ui-in-mcpapps/server +uv sync +uv run python server.py --transport sse --port 8000 +``` + +#### Terminal B: Run Host Web App (Angular) + +```bash +cd samples/mcp/a2ui-in-mcpapps/client +npm run start +``` + +#### Access the Application + +Visit **[http://localhost:4200](http://localhost:4200)** in your browser to load the host container, which will automatically load this Editor app via the MCP server connection. diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/angular.json b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/angular.json new file mode 100644 index 000000000..eca7f53d7 --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/angular.json @@ -0,0 +1,40 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "app": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "index": "src/index.html", + "styles": [] + }, + "configurations": { + "production": { + "optimization": { + "scripts": true, + "styles": true, + "fonts": false + }, + "outputHashing": "none", + "namedChunks": false, + "extractLicenses": true, + "sourceMap": false + } + }, + "defaultConfiguration": "production" + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/inline.js b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/inline.js new file mode 100644 index 000000000..3752dcd5a --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/inline.js @@ -0,0 +1,105 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +const rawDir = path.join(__dirname, '../dist/raw/browser'); +const fallbackDir = path.join(__dirname, '../dist/raw'); +const outputDir = path.join(__dirname, '../public'); +const outputFile = path.join(outputDir, 'editor.html'); + +function getDir() { + if (fs.existsSync(path.join(rawDir, 'index.html'))) return rawDir; + if (fs.existsSync(path.join(fallbackDir, 'index.html'))) return fallbackDir; + throw new Error('Could not find index.html in raw output directories.'); +} + +try { + const buildDir = getDir(); + console.log(`Using build directory: ${buildDir}`); + + let indexHtml = fs.readFileSync(path.join(buildDir, 'index.html'), 'utf-8'); + + const files = fs.readdirSync(buildDir); + const jsFiles = files.filter(f => f.endsWith('.js')); + const cssFiles = files.filter(f => f.endsWith('.css')); + + // Remove existing script tags that point to external files and replace with inlined content + indexHtml = indexHtml.replace(/]*src="([^"]+)"[^>]*><\/script>/g, (match, src) => { + const filePath = path.join(buildDir, src); + if (fs.existsSync(filePath)) { + let content; + if (src === 'main.js') { + console.log(`Bundling and inlining JS: ${src}`); + const bundledPath = path.join(buildDir, 'main.bundled.js'); + try { + // CRITICAL: Modern Angular 17+ builds use ES module code-splitting by default. + // While index.html only points to 'main.js', main.js relies on external relative chunks + // (like markdown renderer, zones, etc.) which browsers block when run inside a sandboxed + // 'srcdoc' iframe due to the lack of an accessible base origin. + // + // We use esbuild's explicit bundling feature here to automatically traverse all + // ES module imports and merge the split chunks into ONE COMPLETELY SELF-CONTAINED monolithic JS file + // before embedding it into the HTML body. + require('child_process').execSync( + `npx -y esbuild "${filePath}" --bundle --outfile="${bundledPath}" --format=esm --allow-overwrite`, + {stdio: 'inherit'}, + ); + content = fs.readFileSync(bundledPath, 'utf-8'); + } catch (e) { + console.warn( + `Warning: bundling failed, falling back to original file. Error: ${e.message}`, + ); + content = fs.readFileSync(filePath, 'utf-8'); + } + } else { + console.log(`Inlining JS: ${src}`); + content = fs.readFileSync(filePath, 'utf-8'); + } + // Remove source maps reference if any to reduce size or avoid errors + const cleanedContent = content.replace(/\/\/# sourceMappingURL=.*\n?/g, ''); + return ``; + } + return match; + }); + + // SCRUB: Angular automatically injects `` for dynamic chunks. + // Since we just forcibly bundled everything into main.js, these relative fetch requests will + // throw noisy 404/CORS network errors inside the iframe if left in place. + indexHtml = indexHtml.replace(/]+>/g, ''); + + // Replace CSS links with style tags + indexHtml = indexHtml.replace( + /]*rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/g, + (match, href) => { + const filePath = path.join(buildDir, href); + if (fs.existsSync(filePath)) { + console.log(`Inlining CSS: ${href}`); + const content = fs.readFileSync(filePath, 'utf-8'); + return ``; + } + return match; + }, + ); + + fs.mkdirSync(outputDir, {recursive: true}); + fs.writeFileSync(outputFile, indexHtml); + console.log(`Successfully inlined app to ${outputFile} (${fs.statSync(outputFile).size} bytes)`); +} catch (err) { + console.error('Error during inlining:', err); + process.exit(1); +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/package.json b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/package.json new file mode 100644 index 000000000..52383ebf3 --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/package.json @@ -0,0 +1,28 @@ +{ + "name": "basic-mcp-app-angular", + "version": "1.0.0", + "dependencies": { + "@a2ui/angular": "file:../../../../../../renderers/angular/dist", + "@a2ui/markdown-it": "file:../../../../../../renderers/markdown/markdown-it", + "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", + "@angular/core": "^21.2.0", + "@angular/platform-browser": "^21.2.0", + "@editorjs/editorjs": "^2.30.0", + "@editorjs/paragraph": "^2.11.6", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular/build": "^21.2.0", + "@angular/cli": "^21.2.0", + "@angular/compiler-cli": "^21.2.0", + "typescript": "~5.9.2" + }, + "scripts": { + "build": "ng build --output-path=../dist/raw --configuration production", + "inline": "node ../inline.js --input ../dist/raw --output ../public/editor.html", + "build:all": "npm run build && npm run inline" + } +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/index.html b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/index.html new file mode 100644 index 000000000..5a0aae56b --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/index.html @@ -0,0 +1,38 @@ + + + + + + + + MCP App (A2UI Renderer) + + + + + + + diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.css b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.css new file mode 100644 index 000000000..c8b5ea90a --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.css @@ -0,0 +1,209 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: block; +} +.app-container { + font-family: 'Outfit', sans-serif; + padding: 24px; + background: #ffffff; + border-radius: 20px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05); +} +.header { + display: flex; + gap: 15px; + align-items: center; + margin-bottom: 24px; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 16px; +} +.header h2 { + margin: 0; + font-size: 1.25rem; + color: #0f172a; + font-weight: 600; +} +button { + background: #0f172a; + color: white; + border: none; + padding: 10px 18px; + border-radius: 12px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} +button:hover { + background: #1e293b; + transform: translateY(-1px); +} +button:disabled { + background: #94a3b8; + color: #cbd5e1; + cursor: not-allowed; + transform: none; +} +::ng-deep a2ui-column section a2ui-button:nth-of-type(1) button { + background: #2563eb !important; + color: white !important; + border: 1px solid #1d4ed8 !important; +} +::ng-deep a2ui-column section a2ui-button:nth-of-type(1) button:hover { + background: #1d4ed8 !important; + border-color: #1e40af !important; +} +::ng-deep a2ui-column section a2ui-button:nth-of-type(2) button { + background: #fef2f2 !important; + color: #b91c1c !important; + border: 1px solid #fecaca !important; +} +::ng-deep a2ui-column section a2ui-button:nth-of-type(2) button:hover { + background: #fee2e2 !important; + color: #991b1b !important; + border-color: #fca5a5 !important; +} +.status-badge { + font-size: 0.85rem; + color: #475569; + background: #f1f5f9; + padding: 6px 12px; + border-radius: 20px; + font-weight: 500; +} +.status-badge.loading { + background: #fef08a; + color: #854d0e; +} +.main-view { + display: grid; + grid-template-columns: 1fr 400px; + gap: 24px; + min-height: 500px; +} +.editor-pane { + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 20px; + background: #ffffff; + display: flex; + flex-direction: column; +} +.a2ui-pane { + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 20px; + background: #f8fafc; +} +#editorjs-container { + flex-grow: 1; + border: 1px solid transparent; + min-height: 400px; +} +.pane-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin-bottom: 16px; + font-weight: 600; + display: block; +} +.pane-hint { + font-size: 0.8rem; + color: #94a3b8; + margin-top: auto; + text-align: center; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 250px; + color: #64748b; + font-style: italic; +} +.debug-section { + margin-top: 20px; + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 20px; + background: #f8fafc; +} +.debug-section h3 { + margin-top: 0; + font-size: 1rem; + color: #0f172a; + margin-bottom: 12px; +} +.json-dump { + background: #1e293b; + color: #e2e8f0; + padding: 15px; + border-radius: 12px; + max-height: 400px; + overflow-y: auto; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-all; + margin: 0; + font-family: 'Fira Code', 'Courier New', monospace; +} + +/* App-specific adjustments for standard CheckBox component */ +a2ui-checkbox { + display: block; + margin-top: 16px; + margin-bottom: 16px; +} + +a2ui-checkbox label { + display: flex; + align-items: center; + gap: 10px; + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + color: #334155; + cursor: pointer; + font-weight: 500; +} + +a2ui-checkbox input[type='checkbox'] { + width: 18px; + height: 18px; + accent-color: #0f172a; /* Primary dark color */ + cursor: pointer; +} + +::ng-deep mark.original, +::ng-deep .cdx-marker.original { + background-color: #fef2f2; /* Light red */ + color: #b91c1c; +} + +::ng-deep mark.revised, +::ng-deep .cdx-marker.revised { + background-color: #d4edda; /* Light green */ + color: #155724; +} + +::ng-deep mark, +::ng-deep .cdx-marker { + padding: 2px 4px; + border-radius: 3px; +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.html b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.html new file mode 100644 index 000000000..8cfb329ca --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.html @@ -0,0 +1,54 @@ + + +
+
+

Generative Document Editor

+ {{ status() }} +
+ +
+
+
Document Canvas
+
+
Highlight a paragraph to generate tuning controls.
+
+ +
+
A2UI Generative Sidebar
+ @if (surfaces().length === 0) { +
+
+
Inactive
+
+ Highlight text in the editor to design custom AI controls. +
+
+
+ } + @for (entry of surfaces(); track entry[0]) { + + } +
+
+ + @if (rawJson()) { +
+

Surface Definitions

+
{{ rawJson() }}
+
+ } +
diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.ts b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.ts new file mode 100644 index 000000000..93eaf1f2d --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/main.ts @@ -0,0 +1,473 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Component, + ChangeDetectionStrategy, + signal, + computed, + inject, + OnInit, + AfterViewInit, + ElementRef, +} from '@angular/core'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {provideZonelessChangeDetection} from '@angular/core'; +import { + provideA2UI, + MessageProcessor, + Surface, + DEFAULT_CATALOG, + provideMarkdownRenderer, +} from '@a2ui/angular'; +import {renderMarkdown} from '@a2ui/markdown-it'; +import {theme} from './theme'; + +// @ts-ignore +import EditorJS from '@editorjs/editorjs'; +// @ts-ignore +import Paragraph from '@editorjs/paragraph'; + +const A2UI_MIME_TYPE = 'application/json+a2ui'; + +@Component({ + selector: 'editor-mcp-app', + standalone: true, + imports: [Surface], + templateUrl: './main.html', + styleUrl: './main.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class McpAppRoot implements OnInit, AfterViewInit { + private processor = inject(MessageProcessor); + private elementRef = inject(ElementRef); + + protected status = signal('Idle'); + protected rawJson = signal(''); + protected isLoading = signal(false); + + private editorInstance: any; + private currentSelectionBlockId: string | null = null; + private selectionTimeout: any; + private currentRevision: { + text_before: string; + original_text: string; + revised_text: string; + text_after: string; + } | null = null; + + protected surfaces = computed(() => { + return Array.from(this.processor.getSurfaces().entries()); + }); + + ngOnInit() { + this.initializeHandshake(); + this.setupActionRouting(); + } + + ngAfterViewInit() { + this.setupResizeObserver(); + this.initEditor(); + } + + private initEditor() { + this.editorInstance = new EditorJS({ + holder: 'editorjs-container', + placeholder: 'Let`s write an awesome doc!', + tools: { + paragraph: { + class: Paragraph as any, + inlineToolbar: true, + sanitize: { + mark: { + class: true, + }, + }, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: {text: 'Toad walks to the market. The sun shines bright.'}, + }, + { + type: 'paragraph', + data: {text: 'He buys a big red apple. It costs one coin.'}, + }, + { + type: 'paragraph', + data: {text: 'Toad eats his apple. He is happy.'}, + }, + ], + }, + onChange: () => { + this.debouncedSelectionCheck(); + }, + }); + + // Attach mouse up listener ONLY to editor container to detect manual cursor highlight, + // preventing interactions with the sidebar sliders from firing this listener. + const holder = document.getElementById('editorjs-container'); + holder?.addEventListener('mouseup', () => this.debouncedSelectionCheck()); + } + + private debouncedSelectionCheck() { + clearTimeout(this.selectionTimeout); + this.selectionTimeout = setTimeout(() => this.checkTextSelection(), 500); + } + + private async checkTextSelection() { + const selection = window.getSelection(); + const text = selection ? selection.toString().trim() : ''; + + // Only trigger if substantial text highlighted (e.g. > 10 chars) + if (text.length < 10) { + return; + } + + // Confirm cursor actually inside Editor.js + const holder = document.getElementById('editorjs-container'); + if (!holder || !selection?.anchorNode || !holder.contains(selection.anchorNode)) { + return; + } + + try { + const activeIndex = this.editorInstance.blocks.getCurrentBlockIndex(); + // Guard against non-valid index + if (activeIndex < 0) return; + const block = this.editorInstance.blocks.getBlockByIndex(activeIndex); + if (!block) return; + this.currentSelectionBlockId = block.id; + + let fullText = text; + try { + const savedData = await this.editorInstance.save(); + const currentBlockData = savedData.blocks[activeIndex]; + if (currentBlockData && currentBlockData.data && currentBlockData.data.text) { + fullText = currentBlockData.data.text; + } + } catch (e) { + console.warn('[EditorApp] Failed to save editor data', e); + } + + console.log('[EditorApp] Highlight detected:', text); + this.fetchTuningControls(text, fullText); + } catch (e) { + console.warn('[EditorApp] Block detection failed', e); + } + } + + private fetchTuningControls(text: string, fullText: string) { + this.status.set('AI Thinking...'); + this.isLoading.set(true); + + const requestId = 'get-controls-' + Date.now(); + this.postToParent({ + jsonrpc: '2.0', + id: requestId, + method: 'ui/smart_editor_get_controls', + params: {text: text, full_text: fullText}, + }); + + const handler = (event: MessageEvent) => { + if (event.data.id !== requestId) return; + + this.isLoading.set(false); + window.removeEventListener('message', handler); + + if (event.data.error) { + this.status.set('Error: ' + event.data.error.message); + return; + } + + const content = event.data.result; + if (!Array.isArray(content)) return; + + try { + const messages = this.getA2UIMessages(content); + if (!messages) { + this.status.set('Ready.'); + return; + } + this.processor.clearSurfaces(); + this.processor.processMessages(messages); + this.status.set('UI Generated'); + } catch (e: any) { + this.status.set('Parse Error'); + } + }; + window.addEventListener('message', handler); + } + + private setupResizeObserver() { + const observer = new ResizeObserver(() => { + const height = this.elementRef.nativeElement.scrollHeight; + this.postToParent({ + jsonrpc: '2.0', + method: 'ui/resize', + params: {height}, + }); + }); + observer.observe(this.elementRef.nativeElement); + } + + private initializeHandshake() { + this.postToParent({ + jsonrpc: '2.0', + id: 'init-1', + method: 'ui/initialize', + params: {}, + }); + } + + private setupActionRouting() { + this.processor.events.subscribe(event => { + // In v0.8/v0.9, event structure holds the userAction data + const action = event.message.userAction; + if (!action) return; + + // Handle Accept/Reject actions + if (action.name === 'smart_editor_accept') { + this.handleAccept(); + return; + } + if (action.name === 'smart_editor_reject') { + this.handleReject(); + return; + } + + // Explicitly filter for the specific apply button action + if (action.name !== 'smart_editor_apply') return; + + const surfaceId = action.surfaceId; + const requestId = 'action-' + Date.now(); + + // 1. Construct execution payload by merging userAction context WITH full local Data Model + const params: any = {}; + + // Look up surface data model values (sliders, text inputs, etc) + const surfaceEntry = this.processor.getSurfaces().get(surfaceId); + if (surfaceEntry && surfaceEntry.dataModel) { + for (const [key, val] of surfaceEntry.dataModel.entries()) { + params[key] = val; + } + } + + // Merge specific action payload contexts (labels, overrides) + if (action.context && typeof action.context === 'object') { + Object.assign(params, action.context); + } + + this.status.set('Revising text...'); + this.isLoading.set(true); + + this.postToParent({ + jsonrpc: '2.0', + id: requestId, + method: `ui/${action.name}`, + params: params, + }); + + const handler = (msgEvent: MessageEvent) => { + if (msgEvent.data.id !== requestId) return; + this.isLoading.set(false); + window.removeEventListener('message', handler); + + const result = msgEvent.data.result; + if (!result) return; + + if (!Array.isArray(result)) return; + + // CASE A: The result might be standard A2UI response (chain actions) + const a2uiMsg = this.getA2UIMessages(result); + if (a2uiMsg) { + this.processor.processMessages(a2uiMsg); + this.status.set('Done.'); + return; + } + + // CASE B: Plain text result (returned from smart_editor_apply) + const textRes = result.find((c: any) => c.type === 'text'); + if (textRes && textRes.text) { + this.handleTextRevision(textRes.text); + } + }; + window.addEventListener('message', handler); + }); + } + + private applyRewriteToEditor(newText: string) { + if (this.currentSelectionBlockId === null) return; + + try { + // Update active block in editor instance using block ID + this.editorInstance.blocks.update(this.currentSelectionBlockId, { + text: newText, + }); + } catch (e) { + console.error('[EditorApp] Failed to update block', e); + } + } + + private handleTextRevision(text: string) { + try { + const parsed = JSON.parse(text); + this.currentRevision = parsed; + const reconstructedText = `${parsed.text_before}${parsed.original_text}${parsed.revised_text}${parsed.text_after}`; + this.applyRewriteToEditor(reconstructedText); + this.status.set('Text Updated'); + } catch (e) { + console.error('[EditorApp] Failed to parse revision JSON', e); + // Fallback to just showing the text if it's not JSON + this.applyRewriteToEditor(text); + this.status.set('Text Updated (Raw)'); + } + this.showAcceptRejectButtons(); + } + + private showAcceptRejectButtons() { + const surface_id = 'editor-controls'; + const a2ui_messages = [ + { + surfaceUpdate: { + surfaceId: surface_id, + components: [ + {id: 'root', component: {Card: {child: 'col'}}}, + { + id: 'title', + component: { + Text: {text: {literalString: 'Revision Options'}, usageHint: 'h3' as const}, + }, + }, + {id: 'accept_btn_txt', component: {Text: {text: {literalString: 'Accept'}}}}, + { + id: 'accept_btn', + component: { + Button: { + child: 'accept_btn_txt', + action: {name: 'smart_editor_accept'}, + primary: true, + }, + }, + }, + {id: 'reject_btn_txt', component: {Text: {text: {literalString: 'Reject'}}}}, + { + id: 'reject_btn', + component: { + Button: { + child: 'reject_btn_txt', + action: {name: 'smart_editor_reject'}, + primary: false, + }, + }, + }, + { + id: 'col', + component: { + Column: {children: {explicitList: ['title', 'accept_btn', 'reject_btn']}}, + }, + }, + ], + }, + }, + { + beginRendering: { + surfaceId: surface_id, + root: 'root', + }, + }, + ]; + this.processor.processMessages(a2ui_messages as any); + } + + private handleAccept() { + console.log( + '[EditorApp] handleAccept called. BlockId:', + this.currentSelectionBlockId, + 'Revision:', + this.currentRevision, + ); + if (!this.currentSelectionBlockId || !this.currentRevision) { + console.log( + '[EditorApp] handleAccept returning early because blockId or revision is missing.', + ); + return; + } + + const {text_before, revised_text, text_after} = this.currentRevision; + const acceptedText = `${text_before}${revised_text}${text_after}`; + console.log('[EditorApp] Applying accepted text:', acceptedText); + + this.editorInstance.blocks.update(this.currentSelectionBlockId, {text: acceptedText}); + this.processor.clearSurfaces(); + this.status.set('Revision Accepted'); + this.currentRevision = null; + } + + private handleReject() { + console.log( + '[EditorApp] handleReject called. BlockId:', + this.currentSelectionBlockId, + 'Revision:', + this.currentRevision, + ); + if (!this.currentSelectionBlockId || !this.currentRevision) { + console.log( + '[EditorApp] handleReject returning early because blockId or revision is missing.', + ); + return; + } + + const {text_before, original_text, text_after} = this.currentRevision; + const rejectedText = `${text_before}${original_text}${text_after}`; + console.log('[EditorApp] Applying rejected text:', rejectedText); + + this.editorInstance.blocks.update(this.currentSelectionBlockId, {text: rejectedText}); + this.processor.clearSurfaces(); + this.status.set('Revision Rejected'); + this.currentRevision = null; + } + + private getA2UIMessages(content: any[]): any[] | null { + const a2uiResource = content.find( + (c: any) => c.type === 'resource' && c.resource?.mimeType === A2UI_MIME_TYPE, + ); + if (!a2uiResource || !a2uiResource.resource?.text) { + return null; + } + const text = a2uiResource.resource.text; + const messages = JSON.parse(text); + this.rawJson.set(JSON.stringify(messages, null, 2)); + return messages; + } + + private postToParent(msg: any) { + // Relay up to top host parent window + window.parent.postMessage(msg, 'http://localhost:4200'); + } +} + +bootstrapApplication(McpAppRoot, { + providers: [ + provideZonelessChangeDetection(), + provideA2UI({ + catalog: DEFAULT_CATALOG, + theme: theme, + }), + provideMarkdownRenderer(renderMarkdown), + ], +}).catch(err => console.error(err)); diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/theme.ts b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/theme.ts new file mode 100644 index 000000000..41dc0069f --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/src/theme.ts @@ -0,0 +1,525 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as Styles from '@a2ui/web_core/styles/index'; +import * as Types from '@a2ui/web_core/types/types'; + +/** Elements */ + +const a = { + 'typography-f-sf': true, + 'typography-fs-n': true, + 'typography-w-500': true, + 'layout-as-n': true, + 'layout-dis-iflx': true, + 'layout-al-c': true, +}; + +const audio = { + 'layout-w-100': true, +}; + +const body = { + 'typography-f-s': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-mt-0': true, + 'layout-mb-2': true, + 'typography-sz-bm': true, + 'color-c-n10': true, +}; + +const button = { + 'typography-f-sf': true, + 'typography-fs-n': true, + 'typography-w-500': true, + 'layout-pt-3': true, + 'layout-pb-3': true, + 'layout-pl-5': true, + 'layout-pr-5': true, + 'layout-mb-1': true, + 'border-br-16': true, + 'border-bw-0': true, + 'border-c-n70': true, + 'border-bs-s': true, + 'color-bgc-s30': true, + 'color-c-n100': true, + 'behavior-ho-80': true, +}; + +const heading = { + 'typography-f-sf': true, + 'typography-fs-n': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mb-2': true, + 'color-c-n10': true, +}; + +const h1 = { + ...heading, + 'typography-sz-tl': true, +}; + +const h2 = { + ...heading, + 'typography-sz-tm': true, +}; + +const h3 = { + ...heading, + 'typography-sz-ts': true, +}; + +const iframe = { + 'behavior-sw-n': true, +}; + +const input = { + 'typography-f-sf': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-pl-4': true, + 'layout-pr-4': true, + 'layout-pt-2': true, + 'layout-pb-2': true, + 'border-br-6': true, + 'border-bw-1': true, + 'color-bc-s70': true, + 'border-bs-s': true, + 'layout-as-n': true, + 'color-c-n10': true, +}; + +const p = { + 'typography-f-s': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-m-0': true, + 'typography-sz-bm': true, + 'layout-as-n': true, + 'color-c-n10': true, +}; + +const orderedList = { + 'typography-f-s': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-m-0': true, + 'typography-sz-bm': true, + 'layout-as-n': true, +}; + +const unorderedList = { + 'typography-f-s': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-m-0': true, + 'typography-sz-bm': true, + 'layout-as-n': true, +}; + +const listItem = { + 'typography-f-s': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'layout-m-0': true, + 'typography-sz-bm': true, + 'layout-as-n': true, +}; + +const pre = { + 'typography-f-c': true, + 'typography-fs-n': true, + 'typography-w-400': true, + 'typography-sz-bm': true, + 'typography-ws-p': true, + 'layout-as-n': true, +}; + +const textarea = { + ...input, + 'layout-r-none': true, + 'layout-fs-c': true, +}; + +const video = { + 'layout-el-cv': true, +}; + +const aLight = Styles.merge(a, {'color-c-p30': true}); +const inputLight = Styles.merge(input, {'color-c-n5': true}); +const textareaLight = Styles.merge(textarea, {'color-c-n5': true}); +const buttonLight = Styles.merge(button, {'color-c-n100': true}); +const h1Light = Styles.merge(h1, {'color-c-n5': true}); +const h2Light = Styles.merge(h2, {'color-c-n5': true}); +const h3Light = Styles.merge(h3, {'color-c-n5': true}); +const bodyLight = Styles.merge(body, {'color-c-n5': true}); +const pLight = Styles.merge(p, {'color-c-n60': true}); +const preLight = Styles.merge(pre, {'color-c-n35': true}); +const orderedListLight = Styles.merge(orderedList, { + 'color-c-n35': true, +}); +const unorderedListLight = Styles.merge(unorderedList, { + 'color-c-n35': true, +}); +const listItemLight = Styles.merge(listItem, { + 'color-c-n35': true, +}); + +export const theme: Types.Theme = { + additionalStyles: { + Card: { + width: '100%', + borderRadius: '20px', + background: '#ffffff', + border: '1px solid #e2e8f0', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.05)', + padding: '20px', + boxSizing: 'border-box', + }, + Button: { + width: '100%', + borderRadius: '12px', + background: '#0f172a', + color: 'white', + border: 'none', + fontWeight: '600', + padding: '12px 24px', + marginTop: '12px', + boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', + transition: 'all 0.2s ease', + cursor: 'pointer', + }, + Image: { + maxWidth: '100px', + maxHeight: '100px', + marginLeft: 'auto', + marginRight: 'auto', + borderRadius: '50%', + marginBottom: '16px', + }, + Slider: { + margin: '16px 0', + }, + MultipleChoice: { + margin: '16px 0', + }, + CheckBox: { + margin: '12px 0', + }, + TextField: { + margin: '12px 0', + }, + }, + components: { + AudioPlayer: {}, + Button: { + 'layout-pt-2': true, + 'layout-pb-2': true, + 'layout-pl-5': true, + 'layout-pr-5': true, + 'border-br-2': true, + 'border-bw-0': true, + 'border-bs-s': true, + 'color-bgc-p30': true, + 'color-c-n100': true, + 'behavior-ho-70': true, + }, + Card: { + 'border-br-6': true, + 'color-bgc-p100': true, + 'color-bc-n90': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'layout-pt-10': true, + 'layout-pb-10': true, + 'layout-pl-4': true, + 'layout-pr-4': true, + }, + CheckBox: { + element: { + 'layout-m-0': true, + 'layout-mr-2': true, + 'layout-p-2': true, + 'border-br-12': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'color-bgc-p100': true, + 'color-bc-p60': true, + 'color-c-n30': true, + 'color-c-p30': true, + }, + label: { + 'color-c-p30': true, + 'typography-f-sf': true, + 'typography-v-r': true, + 'typography-w-400': true, + 'layout-flx-1': true, + 'typography-sz-ll': true, + }, + container: { + 'layout-dsp-iflex': true, + 'layout-al-c': true, + }, + }, + Column: {}, + DateTimeInput: { + container: {}, + label: {}, + element: { + 'layout-pt-2': true, + 'layout-pb-2': true, + 'layout-pl-3': true, + 'layout-pr-3': true, + 'border-br-12': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'color-bgc-p100': true, + 'color-bc-p60': true, + 'color-c-n30': true, + }, + }, + Divider: { + 'color-bgc-n90': true, + 'layout-mt-6': true, + 'layout-mb-6': true, + }, + Image: { + all: { + 'border-br-50pc': true, + 'layout-el-cv': true, + 'layout-w-100': true, + 'layout-h-100': true, + 'layout-dsp-flexhor': true, + 'layout-al-c': true, + 'layout-sp-c': true, + 'layout-mb-3': true, + }, + avatar: {}, + header: {}, + icon: {}, + largeFeature: {}, + mediumFeature: {}, + smallFeature: {}, + }, + Icon: { + 'border-br-1': true, + 'layout-p-2': true, + 'color-bgc-n98': true, + 'layout-dsp-flexhor': true, + 'layout-al-c': true, + 'layout-sp-c': true, + }, + List: { + 'layout-g-4': true, + 'layout-p-2': true, + }, + Modal: { + backdrop: {'color-bbgc-p60_20': true}, + element: { + 'border-br-2': true, + 'color-bgc-p100': true, + 'layout-p-4': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'color-bc-p80': true, + }, + }, + MultipleChoice: { + container: { + 'layout-mb-4': true, + 'layout-dsp-flexvrt': true, + }, + label: { + 'typography-sz-bm': true, + 'typography-w-500': true, + 'color-c-n10': true, + 'layout-mb-2': true, + }, + element: { + 'layout-w-100': true, + 'border-br-8': true, + 'layout-p-2': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'color-bc-n80': true, + 'color-bgc-n100': true, + }, + }, + Row: { + 'layout-g-4': true, + 'layout-mb-3': true, + }, + Slider: { + container: { + 'layout-mb-4': true, + 'layout-dsp-flexvrt': true, + }, + label: { + 'typography-sz-bm': true, + 'typography-w-500': true, + 'color-c-n10': true, + 'layout-mb-2': true, + }, + element: { + 'layout-w-100': true, + 'behavior-c-p': true, + }, + }, + Tabs: { + container: {}, + controls: {all: {}, selected: {}}, + element: {}, + }, + Text: { + all: { + 'layout-w-100': true, + 'layout-g-2': true, + 'color-c-p30': true, + }, + h1: { + 'typography-f-sf': true, + 'typography-ta-c': true, + 'typography-v-r': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mr-0': true, + 'layout-ml-0': true, + 'layout-mb-2': true, + 'layout-p-0': true, + 'typography-sz-tl': true, + }, + h2: { + 'typography-f-sf': true, + 'typography-ta-c': true, + 'typography-v-r': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mr-0': true, + 'layout-ml-0': true, + 'layout-mb-2': true, + 'layout-p-0': true, + 'typography-sz-tl': true, + }, + h3: { + 'typography-f-sf': true, + 'typography-ta-c': true, + 'typography-v-r': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mr-0': true, + 'layout-ml-0': true, + 'layout-mb-0': true, + 'layout-p-0': true, + 'typography-sz-ts': true, + }, + h4: { + 'typography-f-sf': true, + 'typography-ta-c': true, + 'typography-v-r': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mr-0': true, + 'layout-ml-0': true, + 'layout-mb-0': true, + 'layout-p-0': true, + 'typography-sz-bl': true, + }, + h5: { + 'typography-f-sf': true, + 'typography-ta-c': true, + 'typography-v-r': true, + 'typography-w-500': true, + 'layout-mt-0': true, + 'layout-mr-0': true, + 'layout-ml-0': true, + 'layout-mb-0': true, + 'layout-p-0': true, + 'color-c-n30': true, + 'typography-sz-bm': true, + 'layout-mb-1': true, + }, + body: { + 'color-c-n10': true, + }, + caption: { + 'color-c-n40': true, + }, + }, + TextField: { + container: { + 'typography-sz-bm': true, + 'layout-w-100': true, + 'layout-g-2': true, + 'layout-dsp-flexhor': true, + 'layout-al-c': true, + }, + label: { + 'layout-flx-0': true, + }, + element: { + 'typography-sz-bm': true, + 'layout-pt-2': true, + 'layout-pb-2': true, + 'layout-pl-3': true, + 'layout-pr-3': true, + 'border-br-12': true, + 'border-bw-1': true, + 'border-bs-s': true, + 'color-bgc-p100': true, + 'color-bc-p60': true, + 'color-c-n30': true, + 'color-c-p30': true, + }, + }, + Video: { + 'border-br-5': true, + 'layout-el-cv': true, + }, + }, + elements: { + a: aLight, + audio, + body: bodyLight, + button: buttonLight, + h1: h1Light, + h2: h2Light, + h3: h3Light, + h4: {}, + h5: {}, + iframe, + input: inputLight, + p: pLight, + pre: preLight, + textarea: textareaLight, + video, + }, + markdown: { + p: [...Object.keys(pLight)], + h1: [...Object.keys(h1Light)], + h2: [...Object.keys(h2Light)], + h3: [...Object.keys(h3Light)], + h4: [], + h5: [], + ul: [...Object.keys(unorderedListLight)], + ol: [...Object.keys(orderedListLight)], + li: [...Object.keys(listItemLight)], + a: [...Object.keys(aLight)], + strong: [], + em: [], + }, +}; diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/editor/tsconfig.app.json b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/tsconfig.app.json new file mode 100644 index 000000000..0f6b1a88b --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/editor/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "./out-tsc/app", + "types": [], + "paths": { + "@a2ui/angular": ["../../../../../../renderers/angular/src/public-api.ts"], + "@angular/*": ["./node_modules/@angular/*"] + } + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": ["src/main.ts"], + "include": ["src/**/*.ts"] +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/inline.js b/samples/mcp/a2ui-in-mcpapps/server/apps/inline.js new file mode 100644 index 000000000..ae2c2cdd5 --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/inline.js @@ -0,0 +1,110 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); +const {execSync} = require('child_process'); + +// Parse arguments +const args = process.argv.slice(2); +const argMap = {}; +for (let i = 0; i < args.length; i += 2) { + const key = args[i].replace(/^--/, ''); + const value = args[i + 1]; + argMap[key] = value; +} + +const inputArg = argMap.input; +const outputArg = argMap.output; + +if (!inputArg || !outputArg) { + console.error('Usage: node inline.js --input --output '); + process.exit(1); +} + +const baseDir = process.cwd(); +const buildDir = path.resolve(baseDir, inputArg); +const outputFile = path.resolve(baseDir, outputArg); +const outputDir = path.dirname(outputFile); + +function getActualBuildDir(dir) { + const rawDir = path.join(dir, 'browser'); + if (fs.existsSync(path.join(rawDir, 'index.html'))) return rawDir; + if (fs.existsSync(path.join(dir, 'index.html'))) return dir; + throw new Error(`Could not find index.html in ${dir} or ${rawDir}`); +} + +try { + const actualBuildDir = getActualBuildDir(buildDir); + console.log(`Using build directory: ${actualBuildDir}`); + + let indexHtml = fs.readFileSync(path.join(actualBuildDir, 'index.html'), 'utf-8'); + + // Remove existing script tags that point to external files and replace with inlined content + indexHtml = indexHtml.replace(/]*src="([^"]+)"[^>]*><\/script>/g, (match, src) => { + const filePath = path.join(actualBuildDir, src); + if (fs.existsSync(filePath)) { + let content; + if (src === 'main.js') { + console.log(`Bundling and inlining JS: ${src}`); + const bundledPath = path.join(actualBuildDir, 'main.bundled.js'); + try { + execSync( + `npx -y esbuild "${filePath}" --bundle --outfile="${bundledPath}" --format=esm --allow-overwrite`, + {stdio: 'inherit'}, + ); + content = fs.readFileSync(bundledPath, 'utf-8'); + } catch (e) { + console.warn( + `Warning: bundling failed, falling back to original file. Error: ${e.message}`, + ); + content = fs.readFileSync(filePath, 'utf-8'); + } + } else { + console.log(`Inlining JS: ${src}`); + content = fs.readFileSync(filePath, 'utf-8'); + } + // Remove source maps reference if any to reduce size or avoid errors + const cleanedContent = content.replace(/\/\/# sourceMappingURL=.*\n?/g, ''); + return ``; + } + return match; + }); + + // SCRUB: Angular automatically injects `` for dynamic chunks. + indexHtml = indexHtml.replace(/]+>/g, ''); + + // Replace CSS links with style tags + indexHtml = indexHtml.replace( + /]*rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/g, + (match, href) => { + const filePath = path.join(actualBuildDir, href); + if (fs.existsSync(filePath)) { + console.log(`Inlining CSS: ${href}`); + const content = fs.readFileSync(filePath, 'utf-8'); + return ``; + } + return match; + }, + ); + + fs.mkdirSync(outputDir, {recursive: true}); + fs.writeFileSync(outputFile, indexHtml); + console.log(`Successfully inlined app to ${outputFile} (${fs.statSync(outputFile).size} bytes)`); +} catch (err) { + console.error('Error during inlining:', err); + process.exit(1); +} diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/src/angular.json b/samples/mcp/a2ui-in-mcpapps/server/apps/src/angular.json index 9b5b50207..5b3ce761c 100644 --- a/samples/mcp/a2ui-in-mcpapps/server/apps/src/angular.json +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/src/angular.json @@ -18,7 +18,11 @@ }, "configurations": { "production": { - "optimization": true, + "optimization": { + "scripts": true, + "styles": true, + "fonts": false + }, "outputHashing": "none", "namedChunks": false, "extractLicenses": true, diff --git a/samples/mcp/a2ui-in-mcpapps/server/apps/src/package.json b/samples/mcp/a2ui-in-mcpapps/server/apps/src/package.json index b1eaa7e0b..86dc9f57d 100644 --- a/samples/mcp/a2ui-in-mcpapps/server/apps/src/package.json +++ b/samples/mcp/a2ui-in-mcpapps/server/apps/src/package.json @@ -5,6 +5,7 @@ "@a2ui/angular": "file:../../../../../../renderers/angular/dist", "@a2ui/markdown-it": "file:../../../../../../renderers/markdown/markdown-it", "@angular/common": "^21.2.0", + "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", "@angular/platform-browser": "^21.2.0", "rxjs": "~7.8.0", @@ -19,7 +20,7 @@ }, "scripts": { "build": "ng build --output-path=../dist/raw --configuration production", - "inline": "node inline.js", + "inline": "node ../inline.js --input ../dist/raw --output ../public/app.html", "build:all": "npm run build && npm run inline" } } diff --git a/samples/mcp/a2ui-in-mcpapps/server/pyproject.toml b/samples/mcp/a2ui-in-mcpapps/server/pyproject.toml index 3536064a9..b48e1913a 100644 --- a/samples/mcp/a2ui-in-mcpapps/server/pyproject.toml +++ b/samples/mcp/a2ui-in-mcpapps/server/pyproject.toml @@ -18,7 +18,9 @@ version = "0.1.0" description = "MCP server that serves an A2UI application resource and interactive tools." dependencies = [ "click>=8.1.8", + "google-genai>=1.27.0", "mcp[cli]>=1.2.0", + "python-dotenv>=1.0.0", "sse-starlette>=3.3.4", "starlette>=0.45.3", "uvicorn>=0.34.0", diff --git a/samples/mcp/a2ui-in-mcpapps/server/server.py b/samples/mcp/a2ui-in-mcpapps/server/server.py index b6d8df1a8..b045571ad 100644 --- a/samples/mcp/a2ui-in-mcpapps/server/server.py +++ b/samples/mcp/a2ui-in-mcpapps/server/server.py @@ -20,6 +20,7 @@ import pathlib import mcp.types as types from mcp.server.lowlevel import Server +import smart_editor_agent # Set up logging for the server (especially useful for SSE debugging) logging.basicConfig(level=logging.INFO) @@ -54,6 +55,12 @@ async def list_resources() -> list[types.Resource]: name="Basic App", mimeType="text/html;profile=mcp-app", description="A simple minimal application", + ), + types.Resource( + uri="ui://editor/app", + name="Editor App", + mimeType="text/html;profile=mcp-app", + description="A rich generative document editor", ) ] @@ -61,11 +68,16 @@ async def list_resources() -> list[types.Resource]: async def read_resource(uri: str) -> str | bytes: if str(uri) == "ui://basic/app": try: - # Resolve the absolute path of apps/app.html app_path = pathlib.Path(__file__).parent / "apps" / "public" / "app.html" return app_path.read_text() except FileNotFoundError: raise ValueError(f"Resource file not found for uri: {uri} at {app_path}") + elif str(uri) == "ui://editor/app": + try: + app_path = pathlib.Path(__file__).parent / "apps" / "public" / "editor.html" + return app_path.read_text() + except FileNotFoundError: + raise ValueError(f"Resource file not found for uri: {uri} at {app_path}") raise ValueError(f"Unknown resource: {uri}") @app.list_tools() @@ -101,6 +113,41 @@ async def list_tools() -> list[types.Tool]: "required": [] } ), + types.Tool( + name="get_editor_app", + title="Get Editor App", + description="Returns the Editor A2UI application resource.", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + types.Tool( + name="smart_editor_get_controls", + title="Get Editor Controls", + description="Generates A2UI tuning controls based on highlighted text.", + inputSchema={ + "type": "object", + "properties": { + "text": {"type": "string"}, + "full_text": {"type": "string"} + }, + "required": ["text"] + } + ), + types.Tool( + name="smart_editor_apply", + title="Apply Editor Revision", + description="Submits user-tuned slider values to rewrite text via Gemini.", + inputSchema={ + "type": "object", + "properties": { + "original_text": {"type": "string"} + }, + "required": ["original_text"] + } + ), ] @app.call_tool() @@ -163,6 +210,50 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, An ] ) + elif name == "get_editor_app": + return [ + types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri="ui://editor/app", + mimeType="text/html;profile=mcp-app", + text="" + ) + ) + ] + + elif name == "smart_editor_get_controls": + text_in = arguments.get("text", "") + full_text = arguments.get("full_text", text_in) + a2ui_payload = smart_editor_agent.generate_controls(text_in, full_text) + + return types.CallToolResult( + content=[ + types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri="a2ui://editor-controls", + mimeType=A2UI_MIME_TYPE, + text=json.dumps(a2ui_payload) + ) + ) + ] + ) + + elif name == "smart_editor_apply": + # Pass all arguments as the parameter dictionary + orig_text = arguments.get("original_text", "") + revised_text = smart_editor_agent.apply_revision(orig_text, arguments) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=revised_text + ) + ] + ) + raise ValueError(f"Unknown tool: {name}") if transport == "sse": diff --git a/samples/mcp/a2ui-in-mcpapps/server/smart_editor_agent.py b/samples/mcp/a2ui-in-mcpapps/server/smart_editor_agent.py new file mode 100644 index 000000000..2449252d6 --- /dev/null +++ b/samples/mcp/a2ui-in-mcpapps/server/smart_editor_agent.py @@ -0,0 +1,325 @@ +# Copyright 2026 Google LLC +import os +import json +from google import genai +from google.genai import types +from dotenv import load_dotenv + +# Load variables from .env +load_dotenv() + +# Initialize client. +client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY")) + +MODEL_NAME = os.getenv("GENAI_MODEL", "gemini-2.5-flash") + +# Fallback controls used if the LLM fails to provide valid JSON components +DEFAULT_CONTROLS = [ + {"type": "slider", "label": "Verbose vs. Concise"}, + {"type": "slider", "label": "Standard vs. Punchy"} +] + +def generate_controls(text: str, full_text: str = None) -> list: + """ + Generates the A2UI layout JSON for the editor sidebar based on text. + We utilize Gemini to generate optimized, expansive tuning controls relevant to the text. + Supports sliders, checkboxes, and selection dropdowns. + """ + controls_schema = { + "type": "object", + "properties": { + "initial_thought": { + "type": "string", + "description": "An insightful, evocative paragraph (1-2 short sentences) offering creative direction, critique, or themes to inspire the writer." + }, + "controls": { + "type": "array", + "description": "A list of 2 to 3 dynamic UI components best suited for tuning the selected text.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["slider", "select", "checkbox"], + "description": "The widget type." + }, + "label": { + "type": "string", + "description": "Human readable label. For sliders MUST ALWAYS follow 'X vs. Y' pattern." + }, + "options": { + "type": "array", + "items": {"type": "string"}, + "description": "Required only when type='select'. The list of string choices to populate dropdown." + } + }, + "required": ["type", "label"] + } + } + }, + "required": ["initial_thought", "controls"] + } + + prompt = f""" + You are an AI inspiration partner and professional editor. Instead of just proposing simple edits, act as a catalyst for creativity—providing evocative feedback and soliciting directions. + Based on the highlighted text, design a highly relevant set of 2 to 3 dynamic, expansive user interface controls to help the user explore ambitious revisions or refinement pathways. + Select ONLY the control types that specifically make sense for this segment; do NOT feel obligated to include one of each. + + Highlighted Text: "{text}" + + Available Control Types: + - "slider": Best for continuous degree changes. You MUST format the label as "X vs. Y" to denote the two polar ends of the scale (e.g., "Academic vs. Casual", "Detailed vs. Concise"). + - "checkbox": Best for enabling/disabling explicit thematic enhancements (e.g., "Inject subtle humor", "Structure as bulleted steps", "Include call to action"). + - "select": Best for choosing from mutually exclusive distinct thematic directions, voice presets, or stylistic templates. Requires an `options` list of short descriptive options (e.g., Atmosphere: ["Ominous", "Optimistic", "Scholarly"]). + """ + + try: + response = client.models.generate_content( + model=MODEL_NAME, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=controls_schema, + ) + ) + config_json = json.loads(response.text) + controls = config_json.get("controls", []) + + if not isinstance(controls, list) or not controls: + controls = DEFAULT_CONTROLS + controls = controls[:3] # cap at 3 controls + summary = config_json.get("initial_thought", "What direction would you like to explore with this segment?") + except Exception as e: + print(f"Error calling Gemini for controls: {e}") + controls = DEFAULT_CONTROLS + summary = "Ready to explore variations. Adjust the dials below to tune the output." + + # Setup data model initial state + data_model_contents = [ + {"key": "summary_text", "valueString": summary}, + {"key": "original_text", "valueString": text}, + {"key": "full_text", "valueString": full_text or text}, + ] + + # Setup component manifest starting with statics + components = [ + {"id": "root", "component": {"Card": {"child": "col"}}}, + {"id": "title", "component": {"Text": {"text": {"literalString": "Inspiration Agent"}, "usageHint": "h3"}}}, + {"id": "summary", "component": {"Text": {"text": {"path": "/summary_text"}, "usageHint": "body"}}}, + {"id": "divider_summary", "component": {"Divider": {"axis": "horizontal"}}}, + ] + + # Column explicit child order + column_children = ["title", "summary", "divider_summary"] + + # Inject generated controls + for i, ctrl in enumerate(controls): + ctype = ctrl.get("type", "slider").lower() + label = ctrl.get("label", "Control") + key = f"control_{i}" + + # Setup default values & UI definition based on type + spacer_id = f"spacer_{i}" + column_children.append(spacer_id) + components.append({"id": spacer_id, "component": {"Text": {"text": {"literalString": " "}}}}) + + if ctype == "checkbox": + data_model_contents.append({"key": key, "valueBoolean": False}) + ctrl_id = f"checkbox_{i}" + column_children.append(ctrl_id) + components.append({ + "id": ctrl_id, + "component": { + "CheckBox": { + "label": {"literalString": label}, + "value": {"path": f"/{key}"} + } + } + }) + elif ctype == "select": + opt_raw = ctrl.get("options", []) + if not isinstance(opt_raw, list) or not opt_raw: + opt_raw = ["Option A", "Option B"] + opt_raw = [str(o) for o in opt_raw] + + # Initialize model with a JSON-stringified object containing the first option wrapped in literalArray. + # This matches the format utilized by the MultipleChoice component logic in the renderer. + init_val = json.dumps({"literalArray": [opt_raw[0]]}) + data_model_contents.append({"key": key, "valueString": init_val}) + + formatted_options = [{"label": {"literalString": o}, "value": o} for o in opt_raw] + lbl_id = f"lbl_{i}" + ctrl_id = f"select_{i}" + column_children.append(lbl_id) + column_children.append(ctrl_id) + + components.append({"id": lbl_id, "component": {"Text": {"text": {"literalString": label}, "usageHint": "body"}}}) + components.append({ + "id": ctrl_id, + "component": { + "MultipleChoice": { + "options": formatted_options, + "selections": {"path": f"/{key}"} + } + } + }) + else: + # Slider + data_model_contents.append({"key": key, "valueNumber": 50}) + lbl_id = f"lbl_{i}" + sld_id = f"slider_{i}" + column_children.append(lbl_id) + column_children.append(sld_id) + components.append({"id": lbl_id, "component": {"Text": {"text": {"literalString": label}, "usageHint": "body"}}}) + components.append({"id": sld_id, "component": {"Slider": {"value": {"path": f"/{key}"}, "minValue": 0, "maxValue": 100}}}) + + # Add end space and apply button + column_children.append("spacer_end") + components.append({"id": "spacer_end", "component": {"Text": {"text": {"literalString": " "}}}}) + + column_children.append("apply_btn") + components.append({"id": "apply_btn_txt", "component": {"Text": {"text": {"literalString": "Generate Revision"}}}}) + components.append({ + "id": "apply_btn", + "component": { + "Button": { + "child": "apply_btn_txt", + "action": { + "name": "smart_editor_apply", + "context": [ + {"key": "control_config_json", "value": {"literalString": json.dumps(controls)}} + ] + } + } + } + }) + + # Finally add the Column housing them all + components.append({ + "id": "col", + "component": { + "Column": { + "children": {"explicitList": column_children} + } + } + }) + + # Construct final A2UI payload + surface_id = "editor-controls" + a2ui_messages = [ + { + "dataModelUpdate": { + "surfaceId": surface_id, + "contents": data_model_contents + } + }, + { + "surfaceUpdate": { + "surfaceId": surface_id, + "components": components + } + }, + { + "beginRendering": { + "surfaceId": surface_id, + "root": "root" + } + } + ] + return a2ui_messages + +def apply_revision(text: str, user_parameters: dict) -> str: + """ + Takes flexible control parameters and original text and calls Gemini to rewrite the content. + Now includes support for parsing selection arrays. + """ + # Extract dynamic configurations + config_raw = user_parameters.get("control_config_json", "[]") + try: + controls = json.loads(config_raw) + except Exception: + controls = [] + + # Construct prompt blocks from active settings + control_prompt_lines = [] + for i, ctrl in enumerate(controls): + ctype = ctrl.get("type", "slider").lower() + label = ctrl.get("label", "Setting") + key = f"control_{i}" + val = user_parameters.get(key) + + if ctype == "checkbox": + status = "Enabled" if val else "Disabled" + control_prompt_lines.append(f"- {label}: {status}") + elif ctype == "select": + # Robustly handle selection recovery. The data model maps object values. + selected_val = "" + if isinstance(val, dict): + # Check standard A2UI data object + s = val.get("literalArray", []) + if isinstance(s, list) and len(s) > 0: + selected_val = str(s[0]) + elif isinstance(val, list) and len(val) > 0: + selected_val = str(val[0]) + elif val: + selected_val = str(val) + + if not selected_val: + # Attempt to extract default from original control definition + defs = ctrl.get("options", []) + selected_val = defs[0] if defs else "Unknown" + control_prompt_lines.append(f"- {label}: {selected_val}") + else: + try: + num_val = float(val if val is not None else 50) / 100.0 + except (ValueError, TypeError): + num_val = 0.5 + control_prompt_lines.append(f"- {label}: {num_val:.2f} (0 meaning low/minimum expression, 1 meaning high/maximum)") + + adjustment_context = "\n ".join(control_prompt_lines) + if not adjustment_context: + adjustment_context = "No specific adjustments chosen." + + full_text = user_parameters.get("full_text", text) + + revision_schema = { + "type": "object", + "properties": { + "text_before": {"type": "string"}, + "original_text": {"type": "string"}, + "revised_text": {"type": "string"}, + "text_after": {"type": "string"} + }, + "required": ["text_before", "original_text", "revised_text", "text_after"] + } + + prompt = f""" + You are a collaborative professional writing assistant. + Analyze the following user input along with their preferred stylistic tuning specifications. + The user has highlighted a segment within a full text block. + You should revise the highlighted segment according to the specifications. + + Identify the text before the highlighted segment, the original highlighted segment itself, the revised version of that segment, and the text after the highlighted segment. + + Full Text Block: "{full_text}" + Highlighted Segment: "{text}" + + User Tuned Specifications: + {adjustment_context} + """ + + try: + response = client.models.generate_content( + model=MODEL_NAME, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=revision_schema, + ) + ) + return response.text.strip() + except Exception as e: + print(f"Error calling Gemini for rewrite: {e}") + return f"[Error occurred during rewrite: {e}]" + + diff --git a/samples/mcp/a2ui-in-mcpapps/server/uv.lock b/samples/mcp/a2ui-in-mcpapps/server/uv.lock index 1ad0575b9..870428f61 100644 --- a/samples/mcp/a2ui-in-mcpapps/server/uv.lock +++ b/samples/mcp/a2ui-in-mcpapps/server/uv.lock @@ -8,7 +8,9 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "google-genai" }, { name = "mcp", extra = ["cli"] }, + { name = "python-dotenv" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn" }, @@ -17,7 +19,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.8" }, + { name = "google-genai", specifier = ">=1.27.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.2.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "sse-starlette", specifier = ">=3.3.4" }, { name = "starlette", specifier = ">=0.45.3" }, { name = "uvicorn", specifier = ">=0.34.0" }, @@ -116,6 +120,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -190,6 +251,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "google-auth" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/18/238d7021d151bdab868f23433817b027dd759135202f4dfce0670d1230ca/google_auth-2.50.0.tar.gz", hash = "sha256:f35eafb191195328e8ce10a7883970877e7aeb49c2bfaa54aa0e394316d353d0", size = 336523, upload-time = "2026-04-30T21:19:29.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/cf/4880c2137c14280b2f59975cdf12cc442bc0ae1f9ea473a26eaa0c146786/google_auth-2.50.0-py3-none-any.whl", hash = "sha256:04382175e28b94f49694977f0a792688b59a668def1499e9d8de996dc9ce5b15", size = 246495, upload-time = "2026-04-30T21:19:27.664Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.74.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/c8/4a8f1de0a3268d526a345b8c74456b3e1e6ffd200982626326cf7ca83e5b/google_genai-1.74.0.tar.gz", hash = "sha256:c4c473cebdeb6e5adbb0639326de66a3a85a2209e0d32de7d66bf05c698abae8", size = 536772, upload-time = "2026-04-29T22:16:35.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/2b/539c328b66f7bfef2df869371a1789361228e5a7694ba02a642608367b46/google_genai-1.74.0-py3-none-any.whl", hash = "sha256:87d0b311c67d4b2a0ca741e9fc6891330c29defae81d46d8db41079aa1a3d80a", size = 790433, upload-time = "2026-04-29T22:16:33.979Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -324,6 +433,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -482,6 +612,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -570,6 +715,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sse-starlette" version = "3.3.4" @@ -595,6 +749,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -631,6 +794,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.42.0" @@ -643,3 +815,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0 wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/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", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/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", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/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", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]