diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..57577cd --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": ["shadcn@latest", "mcp"] + }, + "ai-elements": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://registry.ai-sdk.dev/api/mcp"] + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25f26fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [master, "feature/**"] + pull_request: + branches: [master] + +env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: short + +jobs: + frontend: + name: Frontend Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: TypeScript check + run: bunx tsc --noEmit + + - name: Vite build + run: bun run build + + rust: + name: Rust Check + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src-tauri/target/ + key: ${{ runner.os }}-cargo-ci-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-ci- + + - name: Cargo check + working-directory: src-tauri + run: cargo check --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3a5a5eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,207 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., 0.2.0) โ€” omit the "v" prefix' + required: true + type: string + prerelease: + description: "Mark as pre-release" + required: false + type: boolean + default: false + +permissions: + contents: write + +env: + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: short + +jobs: + build-and-release: + name: Build & Release + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: version + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${GITHUB_REF#refs/tags/v}" + TAG_NAME="${GITHUB_REF#refs/tags/}" + else + VERSION="${{ github.event.inputs.version }}" + TAG_NAME="v${VERSION}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag_name=${TAG_NAME}" >> "$GITHUB_OUTPUT" + echo "๐Ÿ“ฆ Building version: ${VERSION} (tag: ${TAG_NAME})" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src-tauri/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Sync version numbers + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Update package.json + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '${VERSION}'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + # Update tauri.conf.json + node -e " + const fs = require('fs'); + const conf = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf8')); + conf.version = '${VERSION}'; + fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(conf, null, 2) + '\n'); + " + + # Update Cargo.toml + sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" src-tauri/Cargo.toml + + echo "โœ… Synced all version files to ${VERSION}" + + - name: Build Tauri app + run: pnpm tauri build + + - name: Prepare release artifacts + id: artifacts + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Find NSIS installer + INSTALLER=$(find src-tauri/target/release/bundle/nsis -name "*.exe" -type f | head -1) + INSTALLER_NAME="rustservice-${VERSION}-x64-setup.exe" + cp "${INSTALLER}" "${INSTALLER_NAME}" + echo "installer=${INSTALLER_NAME}" >> "$GITHUB_OUTPUT" + + # Portable exe + PORTABLE_NAME="rustservice-${VERSION}-portable.exe" + cp "src-tauri/target/release/rustservice.exe" "${PORTABLE_NAME}" + echo "portable=${PORTABLE_NAME}" >> "$GITHUB_OUTPUT" + + echo "๐Ÿ“ฆ Artifacts ready:" + echo " Installer: ${INSTALLER_NAME}" + echo " Portable: ${PORTABLE_NAME}" + + - name: Generate changelog + id: changelog + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="${{ steps.version.outputs.tag_name }}" + + # Get previous tag + PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -1) + + { + echo "body</dev/null || echo "- Initial release" + else + echo "### Initial Release" + echo "" + git log --pretty=format:"- %s (%h)" --no-merges -20 2>/dev/null || echo "- Initial release" + fi + + echo "" + echo "" + echo "---" + echo "" + echo "### Downloads" + echo "" + echo "| File | Description |" + echo "|------|-------------|" + echo "| \`rustservice-${VERSION}-x64-setup.exe\` | Windows installer (recommended) |" + echo "| \`rustservice-${VERSION}-portable.exe\` | Portable executable (no install needed) |" + echo "" + echo "> **Note:** The portable executable requires WebView2 Runtime to be installed on the target system." + echo "CHANGELOG_EOF" + } >> "$GITHUB_OUTPUT" + + - name: Determine pre-release status + id: prerelease + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + if [[ "${{ github.event.inputs.prerelease }}" == "true" ]] || [[ "${VERSION}" =~ (alpha|beta|rc|dev) ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag_name }} + name: RustService ${{ steps.version.outputs.tag_name }} + body: ${{ steps.changelog.outputs.body }} + draft: false + prerelease: ${{ steps.prerelease.outputs.is_prerelease }} + generate_release_notes: true + files: | + ${{ steps.artifacts.outputs.installer }} + ${{ steps.artifacts.outputs.portable }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 238a0e3..10ac1b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,14 +12,14 @@ Agent-focused development context for RustService, a Tauri + React Windows deskt ### Prerequisites - Windows 10/11 -- Node.js + pnpm +- Bun (https://bun.sh/) - Rust toolchain (https://rustup.rs/) ### Quick Start ```bash -pnpm install # Install frontend dependencies -pnpm tauri dev # Run in development mode (requires admin for some features) -pnpm tauri build # Build portable executable +bun install # Install frontend dependencies +bun tauri dev # Run in development mode (requires admin for some features) +bun tauri build # Build portable executable ``` ### Project Structure @@ -48,7 +48,7 @@ src-tauri/ # Backend (Rust) ### Adding shadcn Components Always use the CLI, never manually create shadcn components: ```bash -pnpm dlx shadcn@latest add +bunx shadcn@latest add ``` Components are configured with `new-york` style and TailwindCSS v4. @@ -312,7 +312,7 @@ Modular service automation with 4-step flow: **Presets โ†’ Queue โ†’ Runner โ†’ ### Architecture - **Presets**: Pre-configured service bundles (Diagnostics, General, Complete, Custom) - **Queue**: Drag-and-drop reordering, enable/disable, configure options -- **Runner**: Executes services sequentially with real-time log streaming +- **Runner**: Executes services sequentially or in parallel (experimental) with real-time log streaming - **Results**: 3 tabs - Findings (detailed), Printout (technical), Customer Print (simplified) ### Key Files @@ -327,11 +327,20 @@ Modular service automation with 4-step flow: **Presets โ†’ Queue โ†’ Runner โ†’ ### Adding a New Service 1. Add `ServiceDefinition` to `get_all_service_definitions()` in `services.rs` 2. Implement service logic in `run_service()` match arm -3. Optionally add to presets in `get_all_presets()` -4. Add icon to `ICON_MAP` in `ServicePage.tsx` if needed +3. Set `exclusive_resources` for parallel mode (see below) +4. Optionally add to presets in `get_all_presets()` +5. Add icon to `ICON_MAP` in `ServicePage.tsx` if needed See `docs/adding-services.md` for detailed instructions. +### Parallel Execution (Experimental) +- **Toggle**: Per-run switch in the queue view footer (not a global setting) +- **Scheduler**: Resource-based concurrent execution โ€” services with overlapping `exclusive_resources` tags are serialized; non-conflicting services run in parallel on separate threads +- **Resource tags**: `network-bandwidth`, `cpu-stress`, `disk-exclusive`, `disk-heavy`, `filesystem-scan` +- **Pause**: Disabled in parallel mode (only cancel supported) +- **Time tracking**: Individual service durations are measured per-thread โ€” ML models are unaffected +- **Report**: `ServiceReport.parallelMode` flag + `currentServiceIndices` for multi-active tracking + ### Tauri Commands | Command | Description | |---------|-------------| @@ -339,7 +348,7 @@ See `docs/adding-services.md` for detailed instructions. | `get_service_presets` | Get preset configurations | | `validate_service_requirements` | Check if programs installed | | `get_service_run_state` | Get current run state (persists across tabs) | -| `run_services` | Execute service queue | +| `run_services` | Execute service queue (accepts optional `parallel: bool`) | | `cancel_service_run` | Cancel running services | | `get_service_report` | Get saved report by ID | | `list_service_reports` | List all saved reports | @@ -401,6 +410,174 @@ function MyList() { ### Adding the Setting Toggle The toggle is in Settings โ†’ Appearance โ†’ Animations. When disabled, all `motion.div` elements render as plain `div` elements with no transitions. +## Agent System + +AI-powered assistant with command execution, memory, and web search. Human-in-the-loop design ensures user control over all system modifications. + +### Architecture +- **Frontend**: React chat UI + Vercel AI SDK (`@ai-sdk/react`, `ai`) +- **Backend**: Rust commands in `src-tauri/src/commands/agent.rs` +- **Storage**: SQLite database at `data/agent.db` +- **Settings**: `data/settings.json` under `agent` key + +### Key Files +| File | Purpose | +|------|---------| +| `src/pages/AgentPage.tsx` | Main chat interface | +| `src/components/agent/ChatMessage.tsx` | Message display with tool calls | +| `src/components/agent/CommandApproval.tsx` | Pending command approval UI | +| `src/components/agent/MemoryBrowser.tsx` | Memory viewing/search | +| `src/lib/agent-tools.ts` | Vercel AI SDK tool definitions | +| `src/types/agent.ts` | TypeScript types | +| `src-tauri/src/commands/agent.rs` | Rust backend commands | +| `src-tauri/src/types/agent.rs` | Rust types | +| `docs/agent-system.md` | Full documentation | + +### โš ๏ธ Critical: Tauri Parameter Naming + +**Tauri does NOT auto-convert between camelCase and snake_case.** + +When calling Tauri commands from TypeScript, **use snake_case** parameter names: + +```typescript +// โŒ WRONG - will fail +await invoke('approve_command', { commandId: id }); + +// โœ… CORRECT - matches Rust parameter name +await invoke('approve_command', { command_id: id }); +``` + +### Tauri Commands +| Command | Parameters (snake_case!) | Description | +|---------|--------------------------|-------------| +| `queue_agent_command` | `command`, `reason` | Queue command for approval | +| `approve_command` | `command_id` | Approve and execute | +| `reject_command` | `command_id` | Reject command | +| `save_memory` | `memory_type`, `content`, `metadata?` | Save to memory | +| `search_memories` | `query`, `memory_type?`, `limit?` | Search memories | +| `get_all_memories` | `memory_type?`, `limit?` | Get all memories | +| `delete_memory` | `memory_id` | Delete a memory | +| `search_tavily` | `query`, `api_key` | Web search via Tavily | +| `search_searxng` | `query`, `instance_url` | Web search via SearXNG | +| `get_command_history` | `limit?` | Get executed commands | +| `list_agent_programs` | - | List tools in data/programs/ | + +### Command Approval Modes +| Mode | Behavior | +|------|----------| +| `always` | All commands require approval (default, safest) | +| `whitelist` | Whitelisted patterns auto-execute, others need approval | +| `yolo` | All commands auto-execute (โš ๏ธ dangerous) | + +### Memory Types +| Type | Purpose | +|------|---------| +| `fact` | User-provided information | +| `solution` | Successful past solutions | +| `conversation` | Chat context fragments | +| `instruction` | Behavioral rules | + +### Adding an Agent Tool +1. Add Rust command in `src-tauri/src/commands/agent.rs` +2. Register in `src-tauri/src/lib.rs` invoke_handler +3. Create Vercel AI SDK tool in `src/lib/agent-tools.ts` +4. Add to tools array in `AgentPage.tsx` + +See `docs/agent-system.md` for complete documentation. + +## MCP Server (Remote Control) + +Model Context Protocol (MCP) server for remote LLM control. Allows external AI systems (Agent Zero, Claude Desktop, etc.) to control this machine via HTTP. + +### Architecture +- **HTTP Server**: Runs on configurable port (default: 8377) +- **Authentication**: Bearer token in Authorization header +- **Protocol**: JSON-RPC 2.0 over HTTP +- **Backend**: Rust module at `src-tauri/src/mcp/` +- **Settings**: `mcpServerEnabled`, `mcpApiKey`, `mcpPort` in AgentSettings + +### Key Files +| File | Purpose | +|------|---------| +| `src-tauri/src/mcp/mod.rs` | Module exports | +| `src-tauri/src/mcp/server.rs` | HTTP server with JSON-RPC handling | +| `src-tauri/src/mcp/tools.rs` | MCP tool definitions | +| `src/pages/SettingsPage.tsx` | MCP settings UI in Agent panel | + +### Available Tools (11) +| Tool | Description | +|------|-------------| +| `execute_command` | Run PowerShell commands | +| `read_file` | Read file contents | +| `write_file` | Write to files | +| `list_dir` | List directory contents | +| `move_file` | Move/rename files | +| `copy_file` | Copy files | +| `get_system_info` | Get system info (CPU, memory, disks) | +| `search_web` | Web search (Tavily/SearXNG) | +| `list_programs` | List portable programs | +| `list_instruments` | List custom scripts | +| `run_instrument` | Run a custom script | + +### JSON-RPC Methods +| Method | Description | +|--------|-------------| +| `initialize` | Initialize connection, get capabilities | +| `tools/list` | Get list of available tools | +| `tools/call` | Execute a tool by name | + +### Endpoints +| Endpoint | Method | Auth | Description | +|----------|--------|------|-------------| +| `/` | GET | No | Health check | +| `/health` | GET | No | Health check | +| `/mcp` | POST | Yes | MCP JSON-RPC endpoint | + +### Quick Start +1. Enable in Settings โ†’ AI Agent โ†’ MCP Server +2. Copy the auto-generated API key +3. Restart the app +4. Connect via: `POST http://localhost:8377/mcp` with `Authorization: Bearer ` + +### Testing +```bash +# Health check +curl http://localhost:8377/ + +# Initialize (get capabilities) +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","id":1}' \ + http://localhost:8377/mcp + +# List tools +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":2}' \ + http://localhost:8377/mcp + +# Execute command +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"execute_command","arguments":{"command":"whoami","reason":"testing"}},"id":3}' \ + http://localhost:8377/mcp + +# Get system info +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_system_info","arguments":{}},"id":4}' \ + http://localhost:8377/mcp +``` + +### Security +- Always use a strong API key +- For remote access, use HTTPS via a reverse proxy +- Requires app restart after configuration changes + ## Dependencies to Know | Package | Purpose | @@ -413,6 +590,11 @@ The toggle is in Settings โ†’ Appearance โ†’ Animations. When disabled, all `mot | `lucide-react` | Icon library | | `class-variance-authority` | Component variants (shadcn) | | `tailwind-merge` | Merge Tailwind classes (shadcn) | +| `ai` | Vercel AI SDK core | +| `@ai-sdk/react` | React hooks for AI SDK | +| `@ai-sdk/openai` | OpenAI provider for AI SDK | +| `@ai-sdk/anthropic` | Anthropic provider for AI SDK | +| `zod` | Schema validation for AI tools | | `sysinfo` | System hardware/OS info (Rust) | | `gfxinfo` | GPU information (Rust) | | `battery` | Battery status (Rust) | @@ -420,4 +602,10 @@ The toggle is in Settings โ†’ Appearance โ†’ Animations. When disabled, all `mot | `chrono` | Timestamps (Rust) | | `image` | Icon conversion (Rust) | | `winapi` | Windows icon extraction (Rust) | +| `rusqlite` | SQLite database for agent memory (Rust) | +| `urlencoding` | URL encoding for search queries (Rust) | +| `rmcp` | Rust MCP SDK for protocol implementation (Rust) | +| `hyper` | HTTP server for MCP (Rust) | +| `hyper-util` | HTTP utilities (Rust) | +| `http-body-util` | HTTP body utilities (Rust) | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9ff335 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +RustService is a portable Windows desktop toolkit for computer repair technicians. Built with **Tauri 2.0** (Rust backend + React frontend), targeting Windows 10/11 only. + +## Commands + +```bash +bun install # Install frontend dependencies +bun tauri dev # Run in dev mode (some features require admin) +bun tauri build # Build executable โ†’ src-tauri/target/release/rustservice.exe +``` + +There are no automated tests โ€” verification is manual (run `pnpm tauri dev` and exercise the UI). + +### Adding shadcn Components +Always use the CLI, never create them manually: +```bash +bunx shadcn@latest add +``` +Components use `new-york` style with TailwindCSS v4. Do NOT manually edit files in `src/components/ui/`. + +## Architecture + +### Frontend (`src/`) +- **`App.tsx`** โ€” Tab-based navigation. Primary tabs hardcoded in `TABS` array; secondary tabs in "More" dropdown; dynamic technician tabs support URLs/iframes. +- **`pages/`** โ€” One component per tab (Agent, Service, SystemInfo, Programs, Scripts, NetworkDiagnostics, StartupManager, EventLog, Bluescreen, Reports, ComponentTest, Shortcuts, Settings). +- **`components/`** โ€” Shared components. Key ones: `settings-context.tsx` (React context), `theme-provider.tsx`, `animation-context.tsx`, `titlebar.tsx`. +- **`types/`** โ€” TypeScript type definitions that mirror Rust structs for IPC safety. Re-exported from `index.ts`. +- **`styles/globals.css`** โ€” All theme variables (oklch colors), TailwindCSS v4 directives. Color schemes added here + in `src/types/settings.ts`. + +### Backend (`src-tauri/src/`) +- **`lib.rs`** โ€” Registers all Tauri IPC commands in `invoke_handler`. +- **`commands/`** โ€” 18 modules, each handling a domain. Most are single files; `agent/` is a directory module split into `mod.rs`, `commands.rs`, `memory.rs`, `files.rs`, `attachments.rs`, `conversations.rs`, `search.rs`. +- **`mcp/`** โ€” HTTP JSON-RPC server (port 8377 default) for remote LLM control via Bearer token auth. +- **`types/`** โ€” Rust struct definitions mirroring frontend types. + +### Data Folder (Portable) +Created at runtime alongside the `.exe` (dev: `src-tauri/data/`): +``` +data/ +โ”œโ”€โ”€ programs/ # Portable tools + icons/ +โ”œโ”€โ”€ reports/ # Service run reports (JSON) +โ”œโ”€โ”€ scripts/ # Custom scripts +โ”œโ”€โ”€ logs/ # App logs +โ”œโ”€โ”€ settings.json # User preferences +โ””โ”€โ”€ agent.db # SQLite for agent memory +``` + +## Key Patterns & Conventions + +### Tauri IPC โ€” Critical: Use snake_case Parameters +Tauri does NOT auto-convert camelCase to snake_case. Always use snake_case when calling commands from TypeScript: +```typescript +// โŒ WRONG +await invoke('approve_command', { commandId: id }); +// โœ… CORRECT +await invoke('approve_command', { command_id: id }); +``` + +### Adding a Tauri Command +1. Add `#[tauri::command]` function in the relevant `src-tauri/src/commands/*.rs` file +2. Register in `invoke_handler` in `src-tauri/src/lib.rs` +3. Add permissions to `src-tauri/capabilities/default.json` if needed +4. Call from frontend: `invoke('command_name', { args })` + +### Settings System +- **Access**: `useSettings()` hook from `@/components/settings-context` +- **Theme**: `useTheme()` hook from `@/components/theme-provider` +- **Adding a setting**: Add to Rust struct in `src-tauri/src/types/settings.rs`, mirror in `src/types/settings.ts`, add match arm in `src-tauri/src/commands/settings.rs`, add UI in `SettingsPage.tsx` + +### Animation System +- Toggle-able via `appearance.enableAnimations` setting +- Import from `@/components/animation-context`: `useAnimation`, `motion`, `AnimatedList`, `AnimatedItem` +- Use preset props (`fadeIn`, `fadeInUp`, `fadeInScale`, `staggerContainer`, `staggerItem`, `hoverScale`, `hoverLift`) + +### Service System +4-step flow: **Presets โ†’ Queue โ†’ Runner โ†’ Results** +- Definitions in `src-tauri/src/commands/services.rs` โ†’ `get_all_service_definitions()` +- Each service implements a match arm in `run_service()` +- Parallel execution uses `exclusive_resources` tags to prevent conflicts +- See `docs/adding-services.md` for the full guide + +### Agent System +- AI chat via Vercel AI SDK (`ai`, `@ai-sdk/react`) with multiple provider support +- Tool definitions in `src/lib/agent-tools.ts`; backend commands in `src-tauri/src/commands/agent.rs` +- Command approval modes: `always` (default), `whitelist`, `yolo` +- Memory stored in SQLite (`data/agent.db`), types: `fact`, `solution`, `conversation`, `instruction` +- See `docs/agent-system.md` for full documentation + +## Known Expected Lint Warnings (CSS) +These are valid TailwindCSS v4 / Windows-specific syntax โ€” ignore them: +- `Unknown at rule @custom-variant`, `@theme`, `@apply` +- `Unknown property: 'app-region'` (titlebar drag region) diff --git a/README.md b/README.md index 9f9f503..dd396cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- RustService Logo + RustService Logo

RustService

A blazing-fast portable toolkit for computer repair technicians @@ -20,14 +20,23 @@ Run diagnostic and maintenance services with a beautiful 4-step workflow: - **Runner** โ†’ Real-time log streaming with live progress - **Results** โ†’ Detailed findings, technical printout, or customer-friendly summary -**19+ Built-in Services:** +**25+ Built-in Services:** | Category | Services | |----------|----------| | Diagnostics | Ping Test, Speedtest, iPerf, WinSAT, SmartCTL, WhyNotWin11 | -| Stress Testing | FurMark, HeavyLoad, CHKDSK | +| Stress Testing | FurMark, HeavyLoad, CHKDSK, USB Stability | | Cleanup | BleachBit, Drive Cleanup, DISM, SFC | | Security | KVRT, AdwCleaner, Trellix Stinger | -| System | Windows Update, Battery Info, Disk Space | +| System | Windows Update, Battery Report, Energy Report, Disk Space, Driver Audit, Installed Software, Network Config, Startup Optimize, Restore Point | + +### ๐Ÿค– AI Agent (ServiceAgent) +An agentic AI assistant with human-in-the-loop tool execution: +- Multi-provider support (OpenAI, Anthropic, Google, xAI, Mistral, Groq, DeepSeek, OpenRouter, Ollama) +- 30+ tools: command execution, file management, web search, service automation +- Command approval modes: Safe (always approve), Whitelist, YOLO (auto-execute) +- Service supervision: agent monitors running services and writes analysis reports +- Persistent conversations with SQLite storage +- MCP server for remote LLM control via HTTP ### ๐Ÿ’ป System Information Comprehensive hardware & OS reporting at a glance: @@ -74,18 +83,18 @@ Comprehensive hardware & OS reporting at a glance: ### Prerequisites - Windows 10/11 -- Node.js + pnpm +- Bun ([bun.sh](https://bun.sh/)) - Rust toolchain ([rustup.rs](https://rustup.rs/)) ### Development ```bash -pnpm install # Install dependencies -pnpm tauri dev # Run in dev mode +bun install # Install dependencies +bun tauri dev # Run in dev mode ``` ### Build ```bash -pnpm tauri build # Build portable executable +bun tauri build # Build portable executable # Output: src-tauri/target/release/rustservice.exe ``` @@ -102,6 +111,7 @@ data/ โ”œโ”€โ”€ reports/ # Saved service reports โ”œโ”€โ”€ logs/ # Application logs โ”œโ”€โ”€ scripts/ # Custom scripts +โ”œโ”€โ”€ agent/ # AI agent data (memory.db, files) โ””โ”€โ”€ settings.json # Your preferences ``` @@ -118,12 +128,14 @@ data/ | System Info | sysinfo, gfxinfo, battery crates | | Animations | Framer Motion | | Drag & Drop | dnd-kit | +| AI Agent | Vercel AI SDK + multi-provider | --- ## ๐Ÿ“š Documentation - [Adding Services](docs/adding-services.md) โ€” Create new diagnostic/maintenance services +- [Agent System](docs/agent-system.md) โ€” AI agent architecture, tools, and MCP server - [Animation System](docs/animations.md) โ€” Framer Motion integration guide --- diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..48a4d50 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1487 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rustservice", + "dependencies": { + "@ai-sdk/anthropic": "^2.0.56", + "@ai-sdk/google": "^2.0.47", + "@ai-sdk/groq": "^2.0.33", + "@ai-sdk/mcp": "^1.0.19", + "@ai-sdk/mistral": "^2.0.26", + "@ai-sdk/openai": "^2.0.87", + "@ai-sdk/react": "^2.0.116", + "@ai-sdk/xai": "^2.0.41", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "~2.4.2", + "@tauri-apps/plugin-opener": "^2", + "@types/node": "^25.0.1", + "ai": "^5.0.114", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.23.26", + "lucide-react": "^0.561.0", + "marked": "^17.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "react-to-print": "^3.2.0", + "recharts": "2.15.4", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "zod": "^4.2.1", + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "shadcn": "^3.6.1", + "typescript": "~5.6.2", + "vite": "^6.0.3", + }, + }, + }, + "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.55", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YDSA2eIQOfByg2Cd1FDtulqYNRSvXzwlHQRHvTl2VbX7oTr+MmR1g/21i5RdK+o15irqB/5VQNVOB0zOVY828Q=="], + + "@ai-sdk/google": ["@ai-sdk/google@2.0.60", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7E3yoeQwynxWRcROAld85U2NW8IDOWkByC+g9zzZ1QzjKr1N9xFWF0F7XfXW09B/CGM+RTk2kqLJ0v7urBSYeQ=="], + + "@ai-sdk/groq": ["@ai-sdk/groq@2.0.36", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rOn7b3VzebDIwY7/daSXEvYG/M4fNEQJ5cwJWTu8rImhf87P8FJaJNSlI5HUej8u+UVhQmi8ShtQdAGnoXD9pA=="], + + "@ai-sdk/mcp": ["@ai-sdk/mcp@1.0.25", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vMlXUPGHGDE2vzLcPR8sw7Dhz2OBjtPU5lB+lIuC1hNQo4REuUC08P0e96/hzBKf4oQYJ8Zo6uP8AG2qThyFbg=="], + + "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4Xey3so9zBieBqjyf5Qk/V/LXbUlcDUCepgwdLkIecoBMVEimZ4GLqcXvLifdmm1207INlNgHlpm+jIhSfn+/A=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.98", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6BI2lpY0WBuLzwz1bVTVkB4gpupisiN5/bFrud0+gHNhgspYOvh5uLf+7sz/NKvAtRB47WEzlBxwDQwGdIpbQw=="], + + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AnGoxVNZ/E3EU4lW12rrufI6riqL2cEv4jk3OrjJ/i54XwR0CJU1V26jXAwxb+Pc+uZmYG++HM+gzXxPQZkMNQ=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], + + "@ai-sdk/react": ["@ai-sdk/react@2.0.152", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.22", "ai": "5.0.150", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", "zod": "^3.25.76 || ^4.1.8" }, "optionalPeers": ["zod"] }, "sha512-F4/5Iu9rlM9zhfXwL6IJFew7/tI48vV9Ihv2Wy9nlTrz0YrtO0epTCroMQFNnUXUnQ9F8+ATxGyKWlGlaGgc4g=="], + + "@ai-sdk/xai": ["@ai-sdk/xai@2.0.62", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.34", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NpUyro5mnMb1d9f9Ks/nSMQ0ulf63XygBKWKE5D9bRU5y8hrolWeP0oSJYirsySTLoSLasPrMsyrXko4/dz/ng=="], + + "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.54.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-41gU3q7v05GM92QPuPUf4CmUw+mmF8p4wLUh6MCRlxpCkJ9ByLcY9jUf6MwrMNmiKyG/rIckNxj9SCfmNCmCqw=="], + + "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="], + + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], + + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ai": ["ai@5.0.150", "", { "dependencies": { "@ai-sdk/gateway": "2.0.55", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-a1tY3kt6NVDKU3ZTdVWDu0RwHpwXCKG0qVzqDVI6kkwnlKFvQRJsq7mPYjI0YSWFkt5DjL/Aq9NrDY4FhgUTyQ=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "framer-motion": ["framer-motion@12.35.1", "", { "dependencies": { "motion-dom": "^12.35.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + + "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.2.0", "", {}, "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.561.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "motion-dom": ["motion-dom@12.35.1", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ=="], + + "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-to-print": ["react-to-print@3.3.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" } }, "sha512-7j9GIeNZA9glZlbv9mIbIHDOOx+WYfRMbJzh04NiSKjdaeGkxJuKjJQrtRuNKtt5AvEVVjrLCPokZ9yJX51Fvg=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="], + + "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@ai-sdk/mcp/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/docs/adding-services.md b/docs/adding-services.md index ab07d54..4501307 100644 --- a/docs/adding-services.md +++ b/docs/adding-services.md @@ -8,7 +8,7 @@ Services are modular diagnostic/maintenance tasks that can be: - Combined into presets (Diagnostics, General, Complete, Custom) - Individually enabled/disabled by users - Configured with custom options -- Run in sequence with live log output +- Run in sequence or in parallel (experimental) with live log output - Have custom results renderers (for findings view and customer print) ## Architecture @@ -72,6 +72,7 @@ impl Service for MyService { }, ], icon: "icon-name".to_string(), // lucide icon name + exclusive_resources: vec![], // Resource tags for parallel mode (see below) } } @@ -320,3 +321,45 @@ If a service run is blocked due to missing programs, a dialog shows what's neede 4. Verify it appears in the queue 5. Run the service and check logs/findings 6. If you added a custom renderer, verify it displays correctly + +--- + +## Parallel Execution (Experimental) + +Services can optionally run in parallel. When the user enables the **Parallel** toggle in the queue view, services without conflicting resource tags execute concurrently, while services sharing any resource tag are serialized. + +### `exclusive_resources` Field + +Each `ServiceDefinition` has an `exclusive_resources: Vec` field that declares which shared resources this service requires exclusive access to. Services with overlapping tags will never run at the same time. + +**Resource tags in use:** + +| Tag | Description | Services | +|-----|-------------|----------| +| `network-bandwidth` | Services that measure network speed | speedtest, iperf | +| `cpu-stress` | CPU/GPU stress tests and benchmarks | heavyload, winsat, furmark | +| `disk-exclusive` | Services that lock a disk volume | chkdsk | +| `disk-heavy` | Heavy disk I/O (repairs, cleanups) | dism, sfc, bleachbit, drivecleanup | +| `filesystem-scan` | Full filesystem scans (virus/malware) | kvrt-scan, adwcleaner, stinger | + +Services with an empty `exclusive_resources` vec (e.g., `ping-test`, `battery-info`, `disk-space`, `network-config`) can run in parallel with anything. + +### How It Works + +1. The scheduler maintains a set of currently-held resource tags +2. For each unstarted service, it checks if any of its `exclusive_resources` overlap with held resources +3. If no conflict, the service starts on a new thread and its resources are marked as held +4. When a service completes, its resources are released, potentially unblocking waiting services +5. Services with no resource tags can always start immediately + +### Choosing Resource Tags + +When adding a new service, consider: +- **Does it heavily use the network?** โ†’ Add `"network-bandwidth"` +- **Does it stress the CPU or GPU?** โ†’ Add `"cpu-stress"` +- **Does it lock a volume/drive?** โ†’ Add `"disk-exclusive"` +- **Does it do heavy disk I/O?** โ†’ Add `"disk-heavy"` +- **Does it scan the entire filesystem?** โ†’ Add `"filesystem-scan"` +- **Is it a lightweight info-gathering tool?** โ†’ Use `vec![]` + +You can also create a new tag if needed. Any string works as a resource tag โ€” services sharing the same tag will be serialized. diff --git a/docs/agent-system.md b/docs/agent-system.md new file mode 100644 index 0000000..cfa155d --- /dev/null +++ b/docs/agent-system.md @@ -0,0 +1,515 @@ +# Agent System Documentation + +The Agent tab provides an agentic AI assistant with human-in-the-loop command execution, persistent memory, and web search capabilities. Inspired by Agent Zero, it features smart memory management, auto-solution saving, context compression, and behavior learning. + +**Designed for Computer Repair Technicians**: The memory system is portable-first, distinguishing between knowledge that travels with you (solutions, preferences) and information specific to the current client's machine (system state, diagnostics). + +## Overview + +The Agent is a conversational AI that can: +- Execute shell commands (with approval) +- Search the web for solutions +- Remember information across sessions using vector embeddings +- **Portable memory** - Solutions and knowledge travel with your USB drive +- **Machine-specific context** - System state stays tied to each client's computer +- Access CLI tools in the programs folder +- Read and write files +- Automatically save successful solutions +- Extract and remember facts from conversations +- Adjust its own behavior based on feedback +- Compress conversation context for long discussions +- Query knowledge base documents + +**Architecture**: Frontend (React + Vercel AI SDK) โ†” Tauri Commands (Rust) โ†” System + +## Key Files + +### Frontend + +| File | Purpose | +|------|---------| +| `src/pages/AgentPage.tsx` | Main chat interface, agent loop, tool execution | +| `src/hooks/useConversations.ts` | Conversation lifecycle (save, load, create) | +| `src/hooks/useServiceSupervision.ts` | Service run event listeners and state | +| `src/lib/agent-tools.ts` | Vercel AI SDK tool definitions (30+ tools) | +| `src/lib/agent-chat.ts` | Multi-provider streaming with `streamText()` | +| `src/lib/agent-activity-utils.ts` | Tool-to-activity mapping and validation | +| `src/lib/agent-loop-queue.ts` | Serialized agent loop with service update coalescing | +| `src/lib/agent-heartbeat.ts` | Watchdog detecting stalled agent loops | +| `src/lib/mcp-manager.ts` | MCP client for connecting to external servers | +| `src/components/agent/ChatMessage.tsx` | Message rendering with interleaved text/tool parts | +| `src/components/agent/AgentActivityItem.tsx` | Tool call status display with approve/reject UI | +| `src/components/agent/ServiceRunMonitor.tsx` | Live service run progress monitor | +| `src/types/agent.ts` | TypeScript type definitions | +| `src/types/agent-activity.ts` | Activity type definitions (24 types) | + +### Backend + +| File | Purpose | +|------|---------| +| `src-tauri/src/commands/agent/mod.rs` | Shared state, DB helpers, re-exports | +| `src-tauri/src/commands/agent/commands.rs` | Command execution and approval workflow | +| `src-tauri/src/commands/agent/memory.rs` | Memory CRUD and vector search | +| `src-tauri/src/commands/agent/files.rs` | File ops, instruments, programs, grep, glob | +| `src-tauri/src/commands/agent/attachments.rs` | File attachment upload and generation | +| `src-tauri/src/commands/agent/conversations.rs` | Conversation persistence | +| `src-tauri/src/commands/agent/search.rs` | Web search (Tavily, SearXNG) | +| `src-tauri/src/types/agent.rs` | Rust type definitions | +| `src-tauri/src/mcp/server.rs` | MCP HTTP server with bearer auth | +| `src-tauri/src/mcp/tools.rs` | MCP tool implementations | + +--- + +## Memory System (Agent Zero-Inspired) + +### Portable Memory Design + +Since RustService is designed for computer repair technicians who run the tool on multiple client machines, the memory system distinguishes between: + +| Scope | Description | Use Case | +|-------|-------------|----------| +| **Global** | Travels with you on USB | Solutions, knowledge, technician preferences, behaviors | +| **Machine** | Stays with current computer | System state, diagnostics, local conversation context | + +When you plug your USB into a new client's computer: +- โœ… Your learned solutions, knowledge base, and preferences are available +- โœ… You can recall fixes that worked on other machines +- โŒ You won't see system info from other clients (privacy + relevance) +- โŒ Old conversation context stays with the machine it was about + +### Memory Types + +| Type | Default Scope | Purpose | +|------|---------------|---------| +| `fact` | **Global** | User-provided information (names, API keys, technician preferences) | +| `solution` | **Global** | Successful solutions from past interactions (portable!) | +| `knowledge` | **Global** | Knowledge base documents (RAG) | +| `behavior` | **Global** | Agent behavior adjustments and personality rules | +| `instruction` | **Global** | Behavioral rules and user instructions | +| `conversation` | Machine | Context fragments from chats (about current computer) | +| `summary` | Machine | Conversation summaries for context compression | +| `system` | Machine | System state snapshots (this computer's info) | + +### Memory Metadata + +Each memory can include: +- `importance` (0-100): Priority score for retrieval +- `accessCount`: How often the memory has been used +- `lastAccessed`: Timestamp of last access +- `sourceConversationId`: Link to originating conversation +- `tags`: Array of categorization tags +- `scope`: "global" or "machine" (portability) +- `machineId`: Computer name (for machine-scoped memories) + +### Smart Memory Features + +#### Auto-Solution Memorization +When enabled, the agent automatically saves successful fixes: +1. Agent runs a command to fix an issue +2. If the command succeeds (exit code 0) +3. The problem + solution is saved as a `solution` memory +4. Future similar issues can recall this solution + +#### Auto-Fact Extraction +Extract key facts from conversations: +- User preferences and requirements +- System information mentioned +- Important context for future reference + +#### System State Learning +When the agent runs system info commands: +- Results are saved as `system` memories +- Future queries can recall without re-running commands +- Reduces need for repeated diagnostic commands + +### Storage + +- **Location**: `data/agent/memory.db` (SQLite database) +- **Schema**: Enhanced with importance, access tracking, embeddings, scope +- **Vector Search**: Cosine similarity for semantic search +- **Portability**: Single file, copies with USB drive +- **Machine ID**: Uses computer name (COMPUTERNAME env var) to identify machines +- **Scope Filtering**: Queries automatically filter machine-scoped memories to current computer + +--- + +## Tauri Command Reference + +### Command Execution + +| Command | Parameters | Description | +|---------|------------|-------------| +| `queue_agent_command` | `command`, `reason` | Queue a command for approval | +| `execute_agent_command` | `command`, `reason` | Execute directly (bypasses approval) | +| `get_pending_commands` | - | Get all pending commands | +| `approve_command` | `command_id` | Approve and execute command | +| `reject_command` | `command_id` | Reject a pending command | +| `get_command_history` | `limit?` | Get executed command history | + +### Memory Operations + +| Command | Parameters | Description | +|---------|------------|-------------| +| `save_memory` | `memory_type`, `content`, `metadata?`, `embedding?`, `importance?`, `source_conversation_id?`, `scope?` | Save to memory (scope: "global" or "machine") | +| `search_memories` | `query`, `memory_type?`, `limit?` | Search memories by content (auto-filters by scope) | +| `search_memories_vector` | `embedding`, `memory_type?`, `limit?` | Semantic search by vector (auto-filters by scope) | +| `get_all_memories` | `memory_type?`, `limit?` | Get all memories (auto-filters by scope) | +| `update_memory` | `memory_id`, `content?`, `metadata?`, `importance?` | Update existing memory | +| `delete_memory` | `memory_id` | Delete a memory | +| `bulk_delete_memories` | `memory_ids` | Delete multiple memories | +| `clear_all_memories` | - | Clear all memories | +| `get_memory_stats` | - | Get memory statistics | +| `increment_memory_access` | `memory_id` | Track memory usage | +| `get_recent_memories` | `limit?` | Get recently accessed memories (auto-filters by scope) | +| `get_machine_id` | - | Get current computer's identifier | + +### Search + +| Command | Parameters | Description | +|---------|------------|-------------| +| `search_tavily` | `query`, `api_key` | Search via Tavily API | +| `search_searxng` | `query`, `instance_url` | Search via SearXNG instance | + +### Files & Programs + +| Command | Parameters | Description | +|---------|------------|-------------| +| `agent_read_file` | `path` | Read file contents | +| `agent_write_file` | `path`, `content` | Write file contents | +| `agent_list_dir` | `path` | List directory contents | +| `agent_move_file` | `src`, `dest` | Move/rename file | +| `agent_copy_file` | `src`, `dest` | Copy file | +| `list_agent_programs` | - | List programs in data/programs/ | +| `list_instruments` | - | List custom scripts in data/instruments/ | + +### Conversations + +| Command | Parameters | Description | +|---------|------------|-------------| +| `create_conversation` | `title?` | Create new conversation | +| `list_conversations` | `limit?` | List all conversations | +| `get_conversation` | `conversation_id` | Get conversation with messages | +| `save_conversation_messages` | `conversation_id`, `messages` | Save messages to conversation | +| `update_conversation_title` | `conversation_id`, `title` | Update conversation title | +| `delete_conversation` | `conversation_id` | Delete conversation | + +--- + +## AI SDK Tools + +### HITL Tools (Require Approval) + +These tools have no `execute` function โ€” the frontend renders an approve/reject UI. + +| Tool | Description | +|------|-------------| +| `execute_command` | Execute PowerShell commands | +| `write_file` | Create or overwrite files | +| `edit_file` | Replace text in files (targeted edits) | +| `generate_file` | Create downloadable files | +| `move_file` | Move or rename files | +| `copy_file` | Copy files | +| `run_service_queue` | Start a service run | +| `pause_service` | Pause running services | +| `resume_service` | Resume paused services | +| `cancel_service` | Cancel running services | + +### Auto-Execute Tools + +| Tool | Description | +|------|-------------| +| `read_file` | Read file contents with pagination | +| `list_dir` | List directory contents | +| `grep` | Search regex across files | +| `glob` | Find files by pattern | +| `search_web` | Search via Tavily/SearXNG | +| `get_system_info` | Hardware and OS details (selectable sections) | +| `list_programs` | List portable tools in data/programs | +| `find_exe` | Find a specific executable | +| `list_instruments` | List custom scripts | +| `run_instrument` | Run a custom script | +| `list_services` | List available services | +| `list_service_presets` | List service presets | +| `check_service_requirements` | Verify required programs | +| `get_service_status` | Get current service run state | +| `get_service_report` | Get a saved report | +| `get_report_statistics` | Get report statistics | +| `edit_finding` | Edit a report finding | +| `add_finding` | Add a finding to a report | +| `remove_finding` | Remove a finding | +| `set_report_summary` | Write report executive summary | +| `set_service_analysis` | Write per-service analysis | +| `set_health_score` | Set health score (0-100) | +| `generate_report_pdf` | Generate PDF report | + +--- + +## Settings + +### Agent Settings + +```json +{ + "agent": { + "provider": "openai", + "model": "gpt-4o-mini", + "apiKeys": { "openai": "sk-..." }, + "approvalMode": "always", + "whitelistedCommands": ["^ipconfig", "^ping "], + "searchProvider": "tavily", + "tavilyApiKey": "tvly-...", + "mcpServerEnabled": false, + "mcpApiKey": "auto-generated", + "mcpPort": 8377 + } +} +``` + +--- + +## Command Approval System + +### Approval Modes + +| Mode | Behavior | +|------|----------| +| `always` | Every command requires manual approval (default, safest) | +| `whitelist` | Commands matching whitelist patterns auto-execute | +| `yolo` | All commands auto-execute (โš ๏ธ dangerous) | + +### Whitelist Patterns + +Patterns are regex strings. Examples: +```typescript +whitelistedCommands: [ + '^ipconfig', // Commands starting with "ipconfig" + '^ping ', // Commands starting with "ping " + '^systeminfo$', // Exact match "systeminfo" +] +``` + +--- + +## Database Schema + +### memories table + +```sql +CREATE TABLE memories ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + content TEXT NOT NULL, + embedding BLOB, + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + importance INTEGER DEFAULT 50, + access_count INTEGER DEFAULT 0, + last_accessed TEXT, + source_conversation_id TEXT, + scope TEXT DEFAULT 'global', -- 'global' or 'machine' + machine_id TEXT -- Computer name for machine-scoped memories +) +``` + +### command_history table + +```sql +CREATE TABLE command_history ( + id TEXT PRIMARY KEY, + command TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL, + output TEXT, + error TEXT, + created_at TEXT NOT NULL +) +``` + +### conversations table + +```sql +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) +``` + +### conversation_messages table + +```sql +CREATE TABLE conversation_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL +) +``` + +--- + +## โš ๏ธ Critical: Tauri Parameter Naming + +**Tauri does NOT auto-convert between camelCase and snake_case.** + +When calling Tauri commands from TypeScript, use **snake_case** parameter names: + +```typescript +// โŒ WRONG +await invoke('approve_command', { commandId: id }); + +// โœ… CORRECT +await invoke('approve_command', { command_id: id }); +``` + +--- + +## Troubleshooting + +### Commands fail silently +Check that frontend invoke calls use **snake_case** parameter names. + +### Memory not persisting +1. Check that `data/agent/memory.db` exists +2. Check file permissions on data folder + +### Search not working +Configure search provider in Settings โ†’ AI Agent: +- **Tavily**: Set API key from [tavily.com](https://tavily.com) +- **SearXNG**: Set instance URL of your SearXNG deployment + +--- + +## Security Considerations + +1. **Never use YOLO mode** on untrusted systems +2. **Review commands** before approving - the AI can make mistakes +3. **Whitelist carefully** - regex patterns can match more than expected +4. **API keys** are stored in settings.json (consider encryption) +5. **Commands run as the app user** - they have your permissions +6. **Memory contains sensitive data** - protect the data folder +7. **Machine-scoped memories provide client privacy** - System info from one client won't leak to another +8. **The memory database travels with USB** - All memories (global and machine) are in the same file, but machine-scoped queries are filtered by computer name + +--- + +## MCP Server (Remote Control) + +The MCP (Model Context Protocol) server enables external AI systems to control this machine remotely via HTTP. + +### Overview + +External LLMs like Agent Zero, Claude Desktop, or custom AI systems can: +- Execute commands on this machine +- Read/write files +- Search the web +- Get system information + +All operations respect the command approval mode setting. + +### Architecture + +``` +External LLM โ†’ HTTP Request โ†’ MCP Server (Rust) โ†’ Tool Execution โ†’ Response + โ†“ + Bearer Token Auth +``` + +### Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `mcpServerEnabled` | `false` | Enable/disable server | +| `mcpApiKey` | Auto-generated | Bearer token for authentication | +| `mcpPort` | `8377` | HTTP server port | + +Settings location: `data/settings.json` under `agent` key. + +### Key Files + +| File | Purpose | +|------|---------| +| `src-tauri/src/mcp/mod.rs` | Module exports | +| `src-tauri/src/mcp/server.rs` | HTTP server with bearer auth, CORS | +| `src-tauri/src/mcp/tools.rs` | 8 MCP tool implementations | + +### Available MCP Tools + +| Tool | Description | +|------|-------------| +| `execute_command` | Run PowerShell commands (Windows) or shell commands (Linux/Mac) | +| `read_file` | Read file contents | +| `write_file` | Write content to a file | +| `list_dir` | List directory contents with file sizes | +| `move_file` | Move or rename files | +| `copy_file` | Copy files | +| `get_system_info` | Get OS and hostname information | +| `search_web` | Search via Tavily or SearXNG | + +### HTTP Endpoints + +| Endpoint | Method | Auth Required | Description | +|----------|--------|---------------|-------------| +| `/` | GET | No | Health check, returns server status | +| `/health` | GET | No | Health check, returns server status | +| `/mcp` | POST | Yes | MCP JSON-RPC endpoint | + +### Usage + +#### Enable the Server + +1. Go to Settings โ†’ AI Agent โ†’ MCP Server +2. Toggle "Enable MCP Server" +3. Copy the auto-generated API key +4. **Restart the application** (required for changes to take effect) + +#### Connect from External LLM + +```bash +# Health check (no auth required) +curl http://localhost:8377/ + +# MCP endpoint (auth required) +curl -X POST \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ + http://localhost:8377/mcp +``` + +#### Remote Access + +For access from other machines on the network: +- Server binds to `0.0.0.0` (all interfaces) +- Use `http://MACHINE_IP:8377/mcp` +- For production: Put behind HTTPS reverse proxy (nginx, Caddy) + +### Security Considerations + +1. **API Key**: Auto-generated, unique per installation. Regenerate if compromised. +2. **Network Exposure**: Server is accessible from LAN by default. Use firewall rules for internet exposure. +3. **HTTPS**: For remote access over internet, always use a reverse proxy with TLS. +4. **Approval Mode**: MCP commands execute immediately without UI approval. Set approval mode accordingly in settings. +5. **Sensitive Operations**: `execute_command` can run any command with user privileges. + +### Troubleshooting + +#### Server not starting +- Check that `mcpServerEnabled` is true in settings +- Check that `mcpApiKey` is set +- Check console output for port binding errors +- Ensure port 8377 is not in use + +#### Unauthorized responses +- Verify Authorization header format: `Bearer ` +- Check API key matches settings exactly +- No extra spaces or characters + +#### Connection refused +- App must be running +- Check firewall allows port 8377 +- Verify correct IP address for remote access + diff --git a/package.json b/package.json index ea9cbcc..b74f900 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,14 @@ "tauri": "tauri" }, "dependencies": { + "@ai-sdk/anthropic": "^2.0.56", + "@ai-sdk/google": "^2.0.47", + "@ai-sdk/groq": "^2.0.33", + "@ai-sdk/mcp": "^1.0.19", + "@ai-sdk/mistral": "^2.0.26", + "@ai-sdk/openai": "^2.0.87", + "@ai-sdk/react": "^2.0.116", + "@ai-sdk/xai": "^2.0.41", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -32,23 +40,29 @@ "@tauri-apps/plugin-dialog": "~2.4.2", "@tauri-apps/plugin-opener": "^2", "@types/node": "^25.0.1", + "ai": "^5.0.114", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.23.26", "lucide-react": "^0.561.0", + "marked": "^17.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", "react-to-print": "^3.2.0", "recharts": "2.15.4", + "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.2.1" }, "devDependencies": { "@tauri-apps/cli": "^2", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "shadcn": "^3.6.1", "typescript": "~5.6.2", "vite": "^6.0.3" } diff --git a/plans/agent-file-handling.md b/plans/agent-file-handling.md new file mode 100644 index 0000000..19b7cf2 --- /dev/null +++ b/plans/agent-file-handling.md @@ -0,0 +1,457 @@ +# Agent File Handling System - Architecture & Implementation Plan + +## Overview + +Comprehensive file handling system for the RustService agent interface supporting heterogeneous file types with dual upload modalities and bidirectional file flow. + +## Architecture Diagram + +```mermaid +flowchart TB + subgraph UserUpload["User Upload Flow"] + U1[Drag & Drop / File Select] --> U2[MIME Type Detection] + U2 --> U3[Size Classification] + U3 -->|Small < 10MB| U4[Direct Upload] + U3 -->|Large 10-100MB| U5[Chunked Upload] + U3 -->|Huge > 100MB| U6[Streaming Upload] + U4 --> U7[Save to data/agent/files/uploaded/] + U5 --> U7 + U6 --> U7 + U7 --> U8[Generate Thumbnail Preview] + U8 --> U9[Auto-extract Content if Text/Code] + U9 --> U10[Attach to Message Metadata] + end + + subgraph AgentGen["Agent Generation Flow"] + A1[Agent calls generate_file] --> A2[HITL Approval Check] + A2 -->|Safe/Whitelist Mode| A3[Queue for Approval] + A2 -->|YOLO Mode| A4[Auto-Execute] + A3 -->|User Approves| A4 + A4 --> A5[Save to data/agent/files/generated/] + A5 --> A6[Create Metadata Sidecar] + A6 --> A7[Return File Reference] + A7 --> A8[Display in Chat] + end + + subgraph FSRef["Filesystem Reference Flow"] + F1[Agent references existing path] --> F2[Validate Path Security] + F2 -->|Within Sandbox| F3[Auto-read Content] + F2 -->|Outside Sandbox| F4[Request User Confirmation] + F3 --> F5[Attach Content to Context] + F4 -->|Approved| F3 + end + + subgraph Storage["Storage Layer"] + S1[data/agent/files/] --> S2[uploaded/ - User Files] + S1 --> S3[generated/ - Agent Files] + S1 --> S4[thumbnails/ - Previews] + S1 --> S5[temp/ - Streaming Buffers] + S2 --> S6[.meta JSON Sidecars] + S3 --> S6 + end + + U10 --> ChatMsg + A8 --> ChatMsg + F5 --> ChatMsg + ChatMsg[Chat Message with Attachments] +``` + +## File Type Categories + +| Category | MIME Types | Handling Strategy | +|----------|------------|-------------------| +| **Text/Code** | text/*, application/json, application/xml, application/javascript | Full content extraction, syntax highlighting | +| **Documents** | application/pdf, application/msword, application/vnd.* | Metadata extraction, thumbnail generation | +| **Images** | image/* | Thumbnail generation, base64 preview, EXIF extraction | +| **Media** | audio/*, video/* | Metadata only, streaming playback link | +| **Binaries** | application/octet-stream, application/x-* | Hash/checksum, size validation | + +## Size Tiers & Handling + +| Tier | Size Range | Upload Strategy | Processing | +|------|------------|-----------------|------------| +| **Small** | < 10MB | Direct base64 transfer | Immediate processing | +| **Large** | 10MB - 100MB | Chunked upload (1MB chunks) | Background processing | +| **Huge** | > 100MB | Streaming with progress | On-demand access only | + +## TypeScript Type Definitions + +### File Attachment (Unified Schema) + +```typescript +interface FileAttachment { + // Core Identity + id: string; // UUID v4 + source: 'upload' | 'generated' | 'filesystem'; + + // File Information + originalName: string; // Original filename + storedName: string; // UUID-based stored filename + mimeType: string; // Detected MIME type + category: FileCategory; // text | document | image | media | binary + size: number; // Bytes + + // Storage Paths + storedPath: string; // Relative to data/agent/files/ + thumbnailPath?: string; // Preview thumbnail path + + // Content (for text/code files) + content?: string; // Extracted text content + encoding?: string; // Content encoding + lineCount?: number; // For code files + + // Metadata + checksum: string; // SHA-256 hash + uploadedAt: string; // ISO 8601 timestamp + expiresAt?: string; // Optional expiration + + // Source-specific metadata + uploadMetadata?: UploadMetadata; + generationMetadata?: GenerationMetadata; + filesystemMetadata?: FilesystemMetadata; +} + +type FileCategory = 'text' | 'code' | 'document' | 'image' | 'media' | 'binary'; + +interface UploadMetadata { + uploadedBy: 'user'; + originalPath?: string; // Original filesystem path if known + autoExtracted: boolean; // Whether content was auto-extracted +} + +interface GenerationMetadata { + generatedBy: 'agent'; + description: string; // Agent's description of the file + toolCallId: string; // Reference to generating tool call + approved: boolean; // Whether HITL approval was obtained +} + +interface FilesystemMetadata { + originalPath: string; // Original absolute path + accessedAt: string; // When agent first referenced it + autoRead: boolean; // Whether content was auto-read +} +``` + +### File Upload State + +```typescript +interface FileUploadState { + file: File; // Browser File object + id: string; // Temporary upload ID + status: 'pending' | 'uploading' | 'processing' | 'complete' | 'error'; + progress: number; // 0-100 + bytesUploaded: number; + totalBytes: number; + error?: string; + attachment?: FileAttachment; // Final attachment when complete +} +``` + +### Activity Types + +```typescript +// New activity type for file generation +type ActivityType = + | 'analyzed_directory' + | 'searched' + | 'analyzed_file' + | 'ran_command' + | 'read_file' + | 'write_file' + | 'move_file' + | 'copy_file' + | 'list_dir' + | 'web_search' + | 'get_system_info' + | 'mcp_tool' + | 'generate_file' // NEW + | 'attach_files'; // NEW + +interface GenerateFileActivity extends BaseActivity { + type: 'generate_file'; + filename: string; + description: string; + mimeType: string; + size: number; + path: string; +} + +interface AttachFilesActivity extends BaseActivity { + type: 'attach_files'; + fileCount: number; + files: Array<{ + name: string; + size: number; + mimeType: string; + }>; +} +``` + +## Rust Type Definitions + +```rust +// src-tauri/src/types/agent.rs additions + +/// File category for organizing and handling files +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FileCategory { + Text, + Code, + Document, + Image, + Media, + Binary, +} + +/// Source of a file attachment +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FileSource { + Upload, // User uploaded + Generated, // Agent generated + Filesystem, // Referenced from filesystem +} + +/// Unified file attachment metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileAttachment { + pub id: String, + pub source: FileSource, + pub original_name: String, + pub stored_name: String, + pub mime_type: String, + pub category: FileCategory, + pub size: u64, + pub stored_path: String, + pub thumbnail_path: Option, + pub content: Option, + pub encoding: Option, + pub line_count: Option, + pub checksum: String, + pub uploaded_at: String, + pub expires_at: Option, + pub metadata: serde_json::Value, +} + +/// File upload request from frontend +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileUploadRequest { + pub file_name: String, + pub mime_type: String, + pub size: u64, + pub content_base64: String, // For small files + pub chunk_index: Option, + pub total_chunks: Option, +} + +/// File generation request from agent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct FileGenerationRequest { + pub filename: String, + pub content: String, + pub description: String, + pub mime_type: Option, +} + +/// Chunk upload status for large files +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChunkUploadStatus { + pub upload_id: String, + pub chunks_received: Vec, + pub chunks_total: u32, + pub bytes_received: u64, + pub bytes_total: u64, + pub complete: bool, +} +``` + +## Tauri Commands + +| Command | Parameters | Returns | Description | +|---------|------------|---------|-------------| +| `save_uploaded_file` | `request: FileUploadRequest` | `FileAttachment` | Save uploaded file with metadata | +| `upload_file_chunk` | `upload_id: String, chunk_index: u32, content_base64: String` | `ChunkUploadStatus` | Upload chunk for large files | +| `finalize_chunked_upload` | `upload_id: String` | `FileAttachment` | Complete chunked upload | +| `generate_agent_file` | `request: FileGenerationRequest` | `FileAttachment` | Save agent-generated file | +| `read_file_content` | `file_id: String, max_bytes?: u64` | `String` | Read file content (text) | +| `read_file_binary` | `file_id: String` | `String` (base64) | Read binary file as base64 | +| `get_file_info` | `file_id: String` | `FileAttachment` | Get file metadata | +| `list_agent_files` | `source?: FileSource, limit?: u32` | `Vec` | List files | +| `delete_agent_file` | `file_id: String` | `()` | Delete file and metadata | +| `create_file_thumbnail` | `file_id: String` | `String` (path) | Generate thumbnail | +| `validate_filesystem_path` | `path: String` | `PathValidationResult` | Check path security | +| `read_filesystem_file` | `path: String, auto_extract?: bool` | `FileAttachment` | Read external file | + +## React Components + +### FileUploadZone +```typescript +interface FileUploadZoneProps { + onFilesUploaded: (attachments: FileAttachment[]) => void; + maxFiles?: number; + maxTotalSize?: number; // bytes + allowedCategories?: FileCategory[]; + enableChunking?: boolean; + chunkSize?: number; // bytes, default 1MB +} +``` + +### FileAttachmentList +```typescript +interface FileAttachmentListProps { + attachments: FileAttachment[]; + onRemove?: (id: string) => void; + onPreview?: (id: string) => void; + readOnly?: boolean; + showContent?: boolean; // For text/code files +} +``` + +### FilePreview +```typescript +interface FilePreviewProps { + attachment: FileAttachment; + maxPreviewSize?: number; // For text files + enableDownload?: boolean; +} +``` + +### ChatMessage (Updated) +- Render file attachments below message content +- Show thumbnails for images +- Show content preview for text/code +- Show download/open actions + +### AgentActivityItem (Updated) +- Add `generate_file` activity rendering +- Show file icon, name, size +- Download button for generated files + +## Agent Tools + +### generate_file (HITL Tool) +```typescript +const generateFileTool = tool({ + description: `Generate a file with content. Creates the file in the agent workspace. +Use this when you need to create reports, logs, scripts, or any file output. +The user will see and can download the generated file.`, + inputSchema: z.object({ + filename: z.string().describe('Name for the file including extension'), + content: z.string().describe('Full content to write to the file'), + description: z.string().describe('Brief description of what this file contains'), + mime_type: z.string().optional().describe('MIME type (auto-detected if not provided)'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error', 'pending']), + file_id: z.string().optional(), + path: z.string().optional(), + error: z.string().optional(), + }), +}); +``` + +### attach_files (Client Tool) +```typescript +const attachFilesTool = tool({ + description: `Attach files from the filesystem to the conversation. +Use this when the user references files they want you to analyze.`, + inputSchema: z.object({ + paths: z.array(z.string()).describe('Absolute paths to files'), + auto_extract: z.boolean().optional().describe('Automatically extract text content'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error', 'partial']), + attachments: z.array(z.object({ + id: z.string(), + name: z.string(), + size: z.number(), + content_preview: z.string().optional(), + })), + errors: z.array(z.string()).optional(), + }), +}); +``` + +## Security Considerations + +1. **Path Validation**: All filesystem paths validated against sandbox escape attempts +2. **Size Limits**: Enforced at upload and generation time +3. **MIME Type Verification**: Magic number detection, not just extension +4. **Content Sanitization**: HTML/JS files served with proper content-type +5. **Checksum Verification**: SHA-256 for integrity +6. **Expiration**: Auto-cleanup of temp files after 30 days + +## Storage Structure + +``` +data/agent/files/ +โ”œโ”€โ”€ uploaded/ # User-uploaded files +โ”‚ โ”œโ”€โ”€ .bin # File content +โ”‚ โ”œโ”€โ”€ .meta # JSON metadata +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ generated/ # Agent-generated files +โ”‚ โ”œโ”€โ”€ .bin +โ”‚ โ”œโ”€โ”€ .meta +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ thumbnails/ # Generated previews +โ”‚ โ”œโ”€โ”€ .png +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ temp/ # Chunked upload buffers + โ””โ”€โ”€ / + โ”œโ”€โ”€ chunk_0 + โ”œโ”€โ”€ chunk_1 + โ””โ”€โ”€ manifest.json +``` + +## Implementation Phases + +### Phase 1: Core Types & Basic Upload +- TypeScript and Rust type definitions +- Simple file upload (small files only) +- Basic attachment display + +### Phase 2: Agent File Generation +- generate_file tool +- HITL integration +- Generated file display + +### Phase 3: Enhanced Features +- Chunked upload for large files +- Thumbnail generation +- Content auto-extraction + +### Phase 4: Advanced Features +- Streaming for huge files +- Filesystem reference support +- Content search/indexing + +## Integration Points + +1. **AgentPage.tsx**: Add file upload zone to input area +2. **ChatMessage.tsx**: Render file attachments +3. **AgentActivityItem.tsx**: Handle generate_file activities +4. **agent-tools.ts**: Add generate_file and attach_files tools +5. **agent-chat.ts**: Include file context in messages +6. **lib.rs**: Register new Tauri commands + +## Testing Checklist + +- [ ] Upload small text file (< 1MB) +- [ ] Upload large file (> 10MB) +- [ ] Upload image with thumbnail +- [ ] Agent generates text file +- [ ] Agent generates code file +- [ ] HITL approval for file generation +- [ ] YOLO mode auto-generation +- [ ] File content auto-extraction +- [ ] Filesystem path reference +- [ ] File download from chat +- [ ] File deletion/cleanup +- [ ] MIME type validation +- [ ] Size limit enforcement diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 62d07fe..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,3184 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@18.3.1) - '@radix-ui/react-alert-dialog': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-collapsible': - specifier: ^1.1.12 - version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dialog': - specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dropdown-menu': - specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-label': - specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-progress': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-scroll-area': - specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-select': - specifier: ^2.2.6 - version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-separator': - specifier: ^1.1.8 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slider': - specifier: ^1.3.6 - version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': - specifier: ^1.2.4 - version: 1.2.4(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-switch': - specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tabs': - specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': - specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tailwindcss/vite': - specifier: ^4.1.18 - version: 4.1.18(vite@6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) - '@tauri-apps/api': - specifier: ^2 - version: 2.9.1 - '@tauri-apps/plugin-dialog': - specifier: ~2.4.2 - version: 2.4.2 - '@tauri-apps/plugin-opener': - specifier: ^2 - version: 2.5.2 - '@types/node': - specifier: ^25.0.1 - version: 25.0.1 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - framer-motion: - specifier: ^12.23.26 - version: 12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - lucide-react: - specifier: ^0.561.0 - version: 0.561.0(react@18.3.1) - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-to-print: - specifier: ^3.2.0 - version: 3.2.0(react@18.3.1) - recharts: - specifier: 2.15.4 - version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - tailwind-merge: - specifier: ^3.4.0 - version: 3.4.0 - tailwindcss: - specifier: ^4.1.18 - version: 4.1.18 - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 - devDependencies: - '@tauri-apps/cli': - specifier: ^2 - version: 2.9.6 - '@types/react': - specifier: ^18.3.1 - version: 18.3.27 - '@types/react-dom': - specifier: ^18.3.1 - version: 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) - typescript: - specifier: ~5.6.2 - version: 5.6.3 - vite: - specifier: ^6.0.3 - version: 6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) - -packages: - - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - - '@floating-ui/react-dom@2.1.6': - resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-alert-dialog@1.1.15': - resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.3': - resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-progress@1.1.8': - resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-separator@1.1.8': - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slider@1.3.6': - resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} - cpu: [x64] - os: [win32] - - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.18': - resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - - '@tauri-apps/api@2.9.1': - resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==} - - '@tauri-apps/cli-darwin-arm64@2.9.6': - resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tauri-apps/cli-darwin-x64@2.9.6': - resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': - resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tauri-apps/cli-linux-arm64-gnu@2.9.6': - resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tauri-apps/cli-linux-arm64-musl@2.9.6': - resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': - resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - - '@tauri-apps/cli-linux-x64-gnu@2.9.6': - resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tauri-apps/cli-linux-x64-musl@2.9.6': - resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tauri-apps/cli-win32-arm64-msvc@2.9.6': - resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tauri-apps/cli-win32-ia32-msvc@2.9.6': - resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@tauri-apps/cli-win32-x64-msvc@2.9.6': - resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tauri-apps/cli@2.9.6': - resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==} - engines: {node: '>= 10'} - hasBin: true - - '@tauri-apps/plugin-dialog@2.4.2': - resolution: {integrity: sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==} - - '@tauri-apps/plugin-opener@2.5.2': - resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.1.7': - resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/node@25.0.1': - resolution: {integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - - '@types/react@18.3.27': - resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - - baseline-browser-mapping@2.9.7: - resolution: {integrity: sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==} - hasBin: true - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - caniuse-lite@1.0.30001760: - resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.0: - resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} - engines: {node: '>=10.13.0'} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} - engines: {node: '>=6.0.0'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - framer-motion@12.23.26: - resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} - engines: {node: '>= 12.0.0'} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.561.0: - resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - motion-dom@12.23.23: - resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} - - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-to-print@3.2.0: - resolution: {integrity: sha512-IX2D0mebKMgYTBD6s5tf9B7YRL3RFWjRoevYK8JKgRwn94Rep7PFZyeOTGjCmXofKB1SKzvPSzDrAMG4I2PIwg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ~19 - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - tailwind-merge@3.4.0: - resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} - - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tw-animate-css@1.4.0: - resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - update-browserslist-db@1.2.2: - resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - -snapshots: - - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.28.5': {} - - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/runtime@7.28.4': {} - - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@dnd-kit/accessibility@3.1.1(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@floating-ui/core@1.7.3': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.4': - dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/dom': 1.7.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@floating-ui/utils@0.2.10': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-context@1.1.3(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-direction@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/rect': 1.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-context': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-slot@1.2.3(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-slot@1.2.4(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.27)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-use-size@1.1.1(@types/react@18.3.27)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.27 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - '@types/react-dom': 18.3.7(@types/react@18.3.27) - - '@radix-ui/rect@1.1.1': {} - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.53.3': - optional: true - - '@rollup/rollup-android-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-x64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-arm64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.53.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.53.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.53.3': - optional: true - - '@tailwindcss/node@4.1.18': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.4 - jiti: 2.6.1 - lightningcss: 1.30.2 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.1.18 - - '@tailwindcss/oxide-android-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.18': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - optional: true - - '@tailwindcss/oxide@4.1.18': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': - dependencies: - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - tailwindcss: 4.1.18 - vite: 6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) - - '@tauri-apps/api@2.9.1': {} - - '@tauri-apps/cli-darwin-arm64@2.9.6': - optional: true - - '@tauri-apps/cli-darwin-x64@2.9.6': - optional: true - - '@tauri-apps/cli-linux-arm-gnueabihf@2.9.6': - optional: true - - '@tauri-apps/cli-linux-arm64-gnu@2.9.6': - optional: true - - '@tauri-apps/cli-linux-arm64-musl@2.9.6': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': - optional: true - - '@tauri-apps/cli-linux-x64-gnu@2.9.6': - optional: true - - '@tauri-apps/cli-linux-x64-musl@2.9.6': - optional: true - - '@tauri-apps/cli-win32-arm64-msvc@2.9.6': - optional: true - - '@tauri-apps/cli-win32-ia32-msvc@2.9.6': - optional: true - - '@tauri-apps/cli-win32-x64-msvc@2.9.6': - optional: true - - '@tauri-apps/cli@2.9.6': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.9.6 - '@tauri-apps/cli-darwin-x64': 2.9.6 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6 - '@tauri-apps/cli-linux-arm64-gnu': 2.9.6 - '@tauri-apps/cli-linux-arm64-musl': 2.9.6 - '@tauri-apps/cli-linux-riscv64-gnu': 2.9.6 - '@tauri-apps/cli-linux-x64-gnu': 2.9.6 - '@tauri-apps/cli-linux-x64-musl': 2.9.6 - '@tauri-apps/cli-win32-arm64-msvc': 2.9.6 - '@tauri-apps/cli-win32-ia32-msvc': 2.9.6 - '@tauri-apps/cli-win32-x64-msvc': 2.9.6 - - '@tauri-apps/plugin-dialog@2.4.2': - dependencies: - '@tauri-apps/api': 2.9.1 - - '@tauri-apps/plugin-opener@2.5.2': - dependencies: - '@tauri-apps/api': 2.9.1 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.28.5 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.28.5 - - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.1.7': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/node@25.0.1': - dependencies: - undici-types: 7.16.0 - - '@types/prop-types@15.7.15': {} - - '@types/react-dom@18.3.7(@types/react@18.3.27)': - dependencies: - '@types/react': 18.3.27 - - '@types/react@18.3.27': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': - dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) - transitivePeerDependencies: - - supports-color - - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 - - baseline-browser-mapping@2.9.7: {} - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.9.7 - caniuse-lite: 1.0.30001760 - electron-to-chromium: 1.5.267 - node-releases: 2.0.27 - update-browserslist-db: 1.2.2(browserslist@4.28.1) - - caniuse-lite@1.0.30001760: {} - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - clsx@2.1.1: {} - - convert-source-map@2.0.0: {} - - csstype@3.2.3: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.0: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.0 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js-light@2.5.1: {} - - detect-libc@2.1.2: {} - - detect-node-es@1.1.0: {} - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.28.4 - csstype: 3.2.3 - - electron-to-chromium@1.5.267: {} - - enhanced-resolve@5.18.4: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - eventemitter3@4.0.7: {} - - fast-equals@5.4.0: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - framer-motion@12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 - tslib: 2.8.1 - optionalDependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - get-nonce@1.0.1: {} - - graceful-fs@4.2.11: {} - - internmap@2.0.3: {} - - jiti@2.6.1: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lightningcss-android-arm64@1.30.2: - optional: true - - lightningcss-darwin-arm64@1.30.2: - optional: true - - lightningcss-darwin-x64@1.30.2: - optional: true - - lightningcss-freebsd-x64@1.30.2: - optional: true - - lightningcss-linux-arm-gnueabihf@1.30.2: - optional: true - - lightningcss-linux-arm64-gnu@1.30.2: - optional: true - - lightningcss-linux-arm64-musl@1.30.2: - optional: true - - lightningcss-linux-x64-gnu@1.30.2: - optional: true - - lightningcss-linux-x64-musl@1.30.2: - optional: true - - lightningcss-win32-arm64-msvc@1.30.2: - optional: true - - lightningcss-win32-x64-msvc@1.30.2: - optional: true - - lightningcss@1.30.2: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - - lodash@4.17.21: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.561.0(react@18.3.1): - dependencies: - react: 18.3.1 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - motion-dom@12.23.23: - dependencies: - motion-utils: 12.23.6 - - motion-utils@12.23.6: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - node-releases@2.0.27: {} - - object-assign@4.1.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-is@16.13.1: {} - - react-is@18.3.1: {} - - react-refresh@0.17.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@18.3.27)(react@18.3.1): - dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.27 - - react-remove-scroll@2.7.2(@types/react@18.3.27)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.27)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.27)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.27)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.27)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.27 - - react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - fast-equals: 5.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - - react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.27 - - react-to-print@3.2.0(react@18.3.1): - dependencies: - react: 18.3.1 - - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.28.4 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.17.21 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - recharts-scale: 0.4.5 - tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 - - rollup@4.53.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - semver@6.3.1: {} - - source-map-js@1.2.1: {} - - tailwind-merge@3.4.0: {} - - tailwindcss@4.1.18: {} - - tapable@2.3.0: {} - - tiny-invariant@1.3.3: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tslib@2.8.1: {} - - tw-animate-css@1.4.0: {} - - typescript@5.6.3: {} - - undici-types@7.16.0: {} - - update-browserslist-db@1.2.2(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.27 - - use-sidecar@1.1.3(@types/react@18.3.27)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.27 - - victory-vendor@36.9.2: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.7 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - - vite@6.4.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.53.3 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.0.1 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.30.2 - - yallist@3.1.1: {} diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..59e48c5 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index afdf68d..32c2769 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1232,6 +1244,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1580,6 +1604,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1823,12 +1857,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1904,6 +1956,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1918,6 +1976,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2476,6 +2535,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2598,6 +2668,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3954,6 +4034,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -4033,6 +4114,52 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -4096,21 +4223,36 @@ dependencies = [ "battery", "chrono", "dirs 5.0.1", + "gethostname", "gfxinfo", + "glob", + "hex", + "http-body-util", + "hyper", + "hyper-util", "image", + "mime_guess", "regex", "reqwest", + "rmcp", + "rusqlite", + "schemars 0.8.22", "serde", "serde_json", + "sha2", "sysinfo", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", + "tower-http", + "tracing", + "urlencoding", "uuid", "widestring", "winapi", + "zerocopy", ] [[package]] @@ -5497,6 +5639,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -5537,6 +5685,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2bdccbb..e4c4ed2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "rustservice" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "Portable Windows desktop toolkit for computer repair technicians" +authors = ["Sonny Taylor"] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -22,10 +22,15 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -sysinfo = { version = "0.37", features = ["serde", "component", "user", "network"] } +sysinfo = { version = "0.37", features = [ + "serde", + "component", + "user", + "network", +] } gfxinfo = "0.1" battery = "0.7" -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "blocking"] } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -33,8 +38,34 @@ image = "0.25" tauri-plugin-dialog = "2" base64 = "0.22" regex = "1.12.2" +glob = "0.3" dirs = "5" +# Agent memory database +rusqlite = { version = "0.32", features = ["bundled"] } +zerocopy = "0.8" # For efficient vector serialization +urlencoding = "2" # For URL encoding in search queries +gethostname = "0.4" # For machine identification in portable memory system +tracing = "0.1" # For structured logging +sha2 = "0.10" # For SHA-256 checksums +hex = "0.4" # For hex encoding +mime_guess = "2" # For MIME type detection from file extension + +# MCP Server +rmcp = { version = "0.1", features = ["server"] } +schemars = "0.8" # Required for tool JSON schema generation +hyper = { version = "1", features = ["server", "http1"] } # HTTP types +hyper-util = { version = "0.1", features = ["tokio"] } # HTTP server utilities +http-body-util = "0.1" # HTTP body utilities +tower-http = { version = "0.6", features = ["cors"] } # CORS for web clients + [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3", features = ["shellapi", "winuser", "wingdi", "libloaderapi", "winbase"] } +winapi = { version = "0.3", features = [ + "shellapi", + "winuser", + "wingdi", + "libloaderapi", + "winbase", + "winver", +] } widestring = "1.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..31e123f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,34 @@ fn main() { - tauri_build::build() + let is_release = std::env::var("PROFILE").unwrap_or_default() == "release"; + + let windows = if is_release { + tauri_build::WindowsAttributes::new().app_manifest( + r#" + + + + + + + + + + + + +"#, + ) + } else { + tauri_build::WindowsAttributes::new() + }; + + let attrs = tauri_build::Attributes::new().windows_attributes(windows); + tauri_build::try_build(attrs).expect("failed to run tauri build"); } diff --git a/src-tauri/energy-report.html b/src-tauri/energy-report.html new file mode 100644 index 0000000..68310b5 --- /dev/null +++ b/src-tauri/energy-report.html @@ -0,0 +1,1341 @@ +๏ปฟ + + + + + + + +

Power Efficiency Diagnostics Report

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Computer NameDESKTOP-PQKCBHD
Scan Time2025-12-20T04:44:11Z
Scan Duration60 seconds +
System ManufacturerLENOVO
System Product Name83D3
BIOS Date07/14/2025
BIOS VersionNBCN28WW
OS Build26200
Platform RolePlatformRoleMobile
Plugged Infalse
Process Count318
Thread Count5361
Report GUID + {aad9f3b8-2b02-48e9-a256-221f4a96267a} +
+

Analysis Results

+ Warning: Events were dropped during tracing. This may result in incomplete analysis results. +

Errors

+
+
+
Power Policy:Sleep timeout is disabled (Plugged In)
+
+
The computer is not configured to automatically sleep after a period of inactivity.
+
+
+
+
+
System Availability Requests:System Required Request
+
+
The program has made a request to prevent the system from automatically entering sleep.
+ + + + + +
Requesting Process\Device\HarddiskVolume3\Users\Sonny Taylor\AppData\Local\PowerToys\PowerToys.Awake.exe
+
+
+
+
+
System Availability Requests:Display Required Request
+
+
The program has made a request to prevent the display from automatically entering a low-power mode.
+ + + + + +
Requesting Process\Device\HarddiskVolume3\Users\Sonny Taylor\AppData\Local\PowerToys\PowerToys.Awake.exe
+
+
+
+
+
CPU Utilization:Processor utilization is high
+
+
The average processor utilization during the trace was high. The system will consume less power when the average processor utilization is very low. Review processor utilization for individual processes to determine which applications and services contribute the most to total processor utilization.
+ + + + + +
Average Utilization (%)16.87
+
+
+

Warnings

+
+
+
Platform Timer Resolution:Outstanding Timer Request
+
+
A program or service has requested a timer resolution smaller than the platform maximum timer resolution.
+ + + + + + + + + + + + + +
Requested Period80000
Requesting Process ID13352
Requesting Process Path\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\chrome.exe
+
+
+
+
+
Platform Timer Resolution:Outstanding Timer Request
+
+
A program or service has requested a timer resolution smaller than the platform maximum timer resolution.
+ + + + + + + + + + + + + +
Requested Period80000
Requesting Process ID23056
Requesting Process Path\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\chrome.exe
+
+
+
+
+
Power Policy:Minimum processor performance state is high (Plugged In)
+
+
The lowest processor performance state is greater than 75% of the maximum processor performance.
+ + + + + +
Minimum performance state (% of maximum performance)80
+
+
+
+
+
CPU Utilization:Analysis Error
+
+
Analysis was partially unsuccessful. Some results are available, but they may be incomplete.
+
+
+
+
+
Platform Power Management Capabilities:Wireless access point does not support WMM Power Save
+
+
The wireless access point the computer is connected to does not support WMM Power Save Mode. The wireless network adapter cannot enter Power Save mode to save energy as defined in Wireless Adapter Power Policy.
+ + + + + + + + + +
SSIDOPTUS_7F6190N
MAC Address4c:22:f3:7f:61:93
+
+
+

Information

+
+
+
Platform Timer Resolution:Platform Timer Resolution
+
+
The default platform timer resolution is 15.6ms (15625000ns) and should be used whenever the system is idle. If the timer resolution is increased, processor power management technologies may not be effective. The timer resolution may be increased due to multimedia playback or graphical animations.
+ + + + + +
Current Timer Resolution (100ns units)156250
+
+
+
+
+
Platform Timer Resolution:Timer Request Stack
+
+
The stack of modules responsible for the lowest platform timer setting in this process.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requested Period80000
Requesting Process ID13352
Requesting Process Path\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\chrome.exe
+ Calling Module Stack + \Device\HarddiskVolume3\Windows\System32\ntdll.dll
\Device\HarddiskVolume3\Windows\System32\kernel32.dll
\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\143.0.7499.111\chrome.dll
\Device\HarddiskVolume3\Windows\System32\kernel32.dll
\Device\HarddiskVolume3\Windows\System32\ntdll.dll
+
+
+
+
+
Platform Timer Resolution:Timer Request Stack
+
+
The stack of modules responsible for the lowest platform timer setting in this process.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Requested Period10000
Requesting Process ID23056
Requesting Process Path\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\chrome.exe
+ Calling Module Stack + \Device\HarddiskVolume3\Windows\System32\ntdll.dll
\Device\HarddiskVolume3\Windows\System32\kernel32.dll
\Device\HarddiskVolume3\Program Files\Google\Chrome\Application\143.0.7499.111\chrome.dll
\Device\HarddiskVolume3\Windows\System32\kernel32.dll
\Device\HarddiskVolume3\Windows\System32\ntdll.dll
+
+
+
+
+
Power Policy:Active Power Plan
+
+
The current power plan in use
+ + + + + + + + + +
+ Plan Name + + OEM Balanced +
Plan GUID{381b4222-f694-41f0-9685-ff5bb260df2e}
+
+
+
+
+
Power Policy:Power Plan Personality (On Battery)
+
+
The personality of the current power plan when the system is on battery power.
+ + + + + +
Personality + Balanced +
+
+
+
+
+
Power Policy:Video Quality (On Battery)
+
+
Enables Windows Media Player to optimize for quality or power savings when playing video.
+ + + + + +
Quality Mode + Balance Video Quality and Power Savings +
+
+
+
+
+
Power Policy:Power Plan Personality (Plugged In)
+
+
The personality of the current power plan when the system is plugged in.
+ + + + + +
Personality + Balanced +
+
+
+
+
+
Power Policy:802.11 Radio Power Policy is Maximum Performance (Plugged In)
+
+
The current power policy for 802.11-compatible wireless network adapters is not configured to use low-power modes.
+
+
+
+
+
Power Policy:Video quality (Plugged In)
+
+
Enables Windows Media Player to optimize for quality or power savings when playing video.
+ + + + + +
Quality Mode + Optimize for Video Quality +
+
+
+
+
+
USB Suspend:Analysis Success
+
+
Analysis was successful. No energy efficiency problems were found. No information was returned.
+
+
+
+
+
Battery:Battery Information
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Battery ID757SunwodaL23D4PF1
ManufacturerSunwoda
Serial Number757
ChemistryLiP
Long Term1
Sealed0
Cycle Count68
Design Capacity84000
Last Full Charge87220
+
+
+
+
+
Platform Power Management Capabilities:Supported Sleep States
+
+
Sleep states allow the computer to enter low-power modes after a period of inactivity. The S3 sleep state is the default sleep state for Windows platforms. The S3 sleep state consumes only enough power to preserve memory contents and allow the computer to resume working quickly. Very few platforms support the S1 or S2 Sleep states.
+ + + + + + + + + + + + + + + + + +
S1 Sleep Supportedfalse
S2 Sleep Supportedfalse
S3 Sleep Supportedfalse
S4 Sleep Supportedtrue
+
+
+
+
+
Platform Power Management Capabilities:Connected Standby Support
+
+
Connected standby allows the computer to enter a low-power mode in which it is always on and connected. If supported, connected standby is used instead of system sleep states.
+ + + + + +
Connected Standby Supportedtrue
+
+
+
+
+
Platform Power Management Capabilities:Adaptive Display Brightness is supported.
+
+
This computer enables Windows to automatically control the brightness of the integrated display.
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index0
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index1
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index2
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index3
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index4
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index5
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index6
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index7
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index8
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index9
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index10
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index11
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index12
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index13
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index14
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Platform Power Management Capabilities:Processor Power Management Capabilities
+
+
Effective processor power management enables the computer to automatically balance performance and energy consumption.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Group0
Index15
Idle State Count3
Idle State TypeNone
Nominal Frequency (MHz)3801
Maximum Performance Percentage170
Lowest Performance Percentage29
Lowest Throttle Percentage10
Performance Controls TypeACPI Collaborative Processor Performance Control
+
+
+
+
+
Device Drivers:Analysis Success
+
+
Analysis was successful. No energy efficiency problems were found. No information was returned.
+
+
+ + diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 8330722..99f11c0 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 67cb458..9e22d85 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 1f6ea85..2399bad 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..ede981f Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 3d3a6c6..781ec25 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index bd271eb..bc4b681 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 88754b5..06e055f 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 98f14b5..7925733 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 69d0a75..15ddfd7 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 59c4486..cb50c45 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index f97550d..3b2b511 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 3229647..83b469c 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index cc86de4..c47d94a 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 65ae104..48e4143 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..80b520d Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d57f304 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..6026f33 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..73e38ed Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2cc0cc4 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..09c2ec9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a4ba8c8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..161d1a8 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a790de0 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..169db0e Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..52321a9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8cbc749 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6bfb148 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..79fb7b6 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6764b33 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..5f7b86a 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..8e656be 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index bf813c5..07d70f1 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..cc51542 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..0c0bdaf Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..0c0bdaf Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..c03d838 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..0ad5073 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..ebb89ce Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..ebb89ce Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..e55ff9b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..0c0bdaf Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..d0c1327 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..d0c1327 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..0f4fa4d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..9ae1981 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..0f4fa4d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..e05d13b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..fda3856 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..e129643 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..c8f1fa4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands/agent/attachments.rs b/src-tauri/src/commands/agent/attachments.rs new file mode 100644 index 0000000..af7e44e --- /dev/null +++ b/src-tauri/src/commands/agent/attachments.rs @@ -0,0 +1,549 @@ +//! File attachment system + +use std::fs; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use chrono::Utc; +use uuid::Uuid; + +use super::{get_agent_dir, get_data_dir_path}; +use crate::types::{ + compute_checksum, format_file_size, FileAttachment, FileAttachmentMetadata, FileCategory, + FileSource, FilesystemMetadata, GenerationMetadata, PathValidationResult, UploadMetadata, + FILE_SIZE_SMALL, MAX_CONTENT_EXTRACTION_SIZE, +}; + +/// Get the agent files directory +fn get_agent_files_dir() -> PathBuf { + get_agent_dir().join("files") +} + +/// Get the uploaded files directory +fn get_uploaded_files_dir() -> PathBuf { + get_agent_files_dir().join("uploaded") +} + +/// Get the generated files directory +fn get_generated_files_dir() -> PathBuf { + get_agent_files_dir().join("generated") +} + +/// Get the thumbnails directory +fn get_thumbnails_dir() -> PathBuf { + get_agent_files_dir().join("thumbnails") +} + +/// Ensure all file directories exist +fn ensure_file_dirs() -> Result<(), String> { + let dirs = [ + get_agent_files_dir(), + get_uploaded_files_dir(), + get_generated_files_dir(), + get_thumbnails_dir(), + ]; + + for dir in &dirs { + fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create directory {}: {}", dir.display(), e))?; + } + + Ok(()) +} + +/// Save metadata sidecar file +fn save_file_metadata(attachment: &FileAttachment) -> Result<(), String> { + let meta_path = Path::new(&attachment.stored_path).with_extension("meta.json"); + let meta_json = serde_json::to_string_pretty(attachment) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + fs::write(&meta_path, meta_json).map_err(|e| format!("Failed to write metadata: {}", e))?; + Ok(()) +} + +/// Load metadata from sidecar file +fn load_file_metadata(stored_path: &str) -> Result { + let meta_path = Path::new(stored_path).with_extension("meta.json"); + let meta_json = + fs::read_to_string(&meta_path).map_err(|e| format!("Failed to read metadata: {}", e))?; + let attachment: FileAttachment = + serde_json::from_str(&meta_json).map_err(|e| format!("Failed to parse metadata: {}", e))?; + Ok(attachment) +} + +/// Extract text content from file if applicable +fn extract_file_content( + path: &Path, + category: &FileCategory, + max_size: usize, +) -> Result, String> { + if !category.should_auto_extract() { + return Ok(None); + } + + let metadata = fs::metadata(path).map_err(|e| format!("Failed to get file metadata: {}", e))?; + + if metadata.len() > max_size as u64 { + return Ok(Some(format!( + "[File too large for content extraction: {}]", + format_file_size(metadata.len()) + ))); + } + + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read file content: {}", e))?; + + Ok(Some(content)) +} + +/// Save an uploaded file from the frontend +#[tauri::command(rename_all = "snake_case")] +pub fn save_uploaded_file( + file_name: String, + mime_type: String, + _size: u64, + content_base64: String, +) -> Result { + ensure_file_dirs()?; + + // Validate file size + let content_bytes = base64::engine::general_purpose::STANDARD + .decode(&content_base64) + .map_err(|e| format!("Failed to decode base64: {}", e))?; + + if content_bytes.len() as u64 > FILE_SIZE_SMALL { + return Err(format!( + "File too large for direct upload ({}). Use chunked upload for files > 10MB.", + format_file_size(content_bytes.len() as u64) + )); + } + + // Generate IDs and paths + let id = Uuid::new_v4().to_string(); + let stored_name = format!("{}", id); + let stored_path = get_uploaded_files_dir().join(&stored_name); + let now = Utc::now().to_rfc3339(); + + // Determine category and MIME type + let category = FileCategory::from_extension(&file_name); + let mime_type = if mime_type.is_empty() { + match category { + FileCategory::Text => "text/plain", + FileCategory::Code => "application/octet-stream", + FileCategory::Document => "application/octet-stream", + FileCategory::Image => "image/png", + FileCategory::Media => "application/octet-stream", + FileCategory::Binary => "application/octet-stream", + } + .to_string() + } else { + mime_type + }; + + // Write file + fs::write(&stored_path, &content_bytes).map_err(|e| format!("Failed to write file: {}", e))?; + + // Compute checksum + let checksum = compute_checksum(&content_bytes); + + // Extract content if applicable + let content = extract_file_content(&stored_path, &category, MAX_CONTENT_EXTRACTION_SIZE)?; + let line_count = content.as_ref().map(|c| c.lines().count() as u32); + + // Create attachment + let attachment = FileAttachment { + id: id.clone(), + source: FileSource::Upload, + original_name: file_name.clone(), + stored_name: stored_name.clone(), + mime_type: mime_type.clone(), + category: category.clone(), + size: content_bytes.len() as u64, + stored_path: stored_path.to_string_lossy().to_string(), + thumbnail_path: None, + content: content.clone(), + encoding: Some("utf-8".to_string()), + line_count, + checksum: checksum.clone(), + uploaded_at: now.clone(), + expires_at: None, + metadata: FileAttachmentMetadata { + upload_metadata: Some(UploadMetadata { + uploaded_by: "user".to_string(), + original_path: None, + auto_extracted: content.is_some(), + }), + generation_metadata: None, + filesystem_metadata: None, + }, + }; + + // Save metadata sidecar + save_file_metadata(&attachment)?; + + Ok(attachment) +} + +/// Generate a file from the agent +#[tauri::command(rename_all = "snake_case")] +pub fn generate_agent_file( + filename: String, + content: String, + description: String, + mime_type: Option, + tool_call_id: String, + approved: bool, +) -> Result { + ensure_file_dirs()?; + + // Generate IDs and paths + let id = Uuid::new_v4().to_string(); + let stored_name = format!("{}", id); + let stored_path = get_generated_files_dir().join(&stored_name); + let now = Utc::now().to_rfc3339(); + + // Determine category and MIME type + let category = FileCategory::from_extension(&filename); + let mime_type = mime_type.unwrap_or_else(|| match category { + FileCategory::Text => "text/plain".to_string(), + FileCategory::Code => "application/octet-stream".to_string(), + FileCategory::Document => "application/octet-stream".to_string(), + FileCategory::Image => "image/png".to_string(), + FileCategory::Media => "application/octet-stream".to_string(), + FileCategory::Binary => "application/octet-stream".to_string(), + }); + + // Write file + fs::write(&stored_path, content.as_bytes()) + .map_err(|e| format!("Failed to write file: {}", e))?; + + // Compute checksum + let checksum = compute_checksum(content.as_bytes()); + + // Count lines for code/text files + let line_count = if category.should_auto_extract() { + Some(content.lines().count() as u32) + } else { + None + }; + + // Create attachment + let attachment = FileAttachment { + id: id.clone(), + source: FileSource::Generated, + original_name: filename.clone(), + stored_name: stored_name.clone(), + mime_type: mime_type.clone(), + category: category.clone(), + size: content.len() as u64, + stored_path: stored_path.to_string_lossy().to_string(), + thumbnail_path: None, + content: if category.should_auto_extract() { + Some(content.clone()) + } else { + None + }, + encoding: Some("utf-8".to_string()), + line_count, + checksum: checksum.clone(), + uploaded_at: now.clone(), + expires_at: None, + metadata: FileAttachmentMetadata { + upload_metadata: None, + generation_metadata: Some(GenerationMetadata { + generated_by: "agent".to_string(), + description: description.clone(), + tool_call_id: tool_call_id.clone(), + approved, + }), + filesystem_metadata: None, + }, + }; + + // Save metadata sidecar + save_file_metadata(&attachment)?; + + Ok(attachment) +} + +/// Read file content as text +#[tauri::command(rename_all = "snake_case")] +pub fn read_file_content(file_id: String) -> Result { + // Try to find the file in uploaded or generated directories + let uploaded_path = get_uploaded_files_dir().join(&file_id); + let generated_path = get_generated_files_dir().join(&file_id); + + let path = if uploaded_path.exists() { + uploaded_path + } else if generated_path.exists() { + generated_path + } else { + return Err(format!("File not found: {}", file_id)); + }; + + fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e)) +} + +/// Read file content as base64 (for binary files) +#[tauri::command(rename_all = "snake_case")] +pub fn read_file_binary(file_id: String) -> Result { + let uploaded_path = get_uploaded_files_dir().join(&file_id); + let generated_path = get_generated_files_dir().join(&file_id); + + let path = if uploaded_path.exists() { + uploaded_path + } else if generated_path.exists() { + generated_path + } else { + return Err(format!("File not found: {}", file_id)); + }; + + let bytes = fs::read(&path).map_err(|e| format!("Failed to read file: {}", e))?; + + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + +/// Get file info by ID +#[tauri::command(rename_all = "snake_case")] +pub fn get_file_info(file_id: String) -> Result { + // Try to load metadata from uploaded or generated directories + let uploaded_meta = get_uploaded_files_dir().join(format!("{}.meta.json", file_id)); + let generated_meta = get_generated_files_dir().join(format!("{}.meta.json", file_id)); + + let meta_path = if uploaded_meta.exists() { + uploaded_meta + } else if generated_meta.exists() { + generated_meta + } else { + return Err(format!("File metadata not found: {}", file_id)); + }; + + let meta_json = + fs::read_to_string(&meta_path).map_err(|e| format!("Failed to read metadata: {}", e))?; + let attachment: FileAttachment = + serde_json::from_str(&meta_json).map_err(|e| format!("Failed to parse metadata: {}", e))?; + + Ok(attachment) +} + +/// List all agent files +#[tauri::command(rename_all = "snake_case")] +pub fn list_agent_files( + source: Option, + limit: Option, +) -> Result, String> { + ensure_file_dirs()?; + + let mut attachments = Vec::new(); + let limit = limit.unwrap_or(100) as usize; + + // Helper to scan a directory + let mut scan_dir = |dir: &PathBuf, expected_source: FileSource| { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + continue; // Skip metadata files + } + + if let Ok(attachment) = load_file_metadata(&path.to_string_lossy()) { + if source + .as_ref() + .map(|s| *s == expected_source) + .unwrap_or(true) + { + attachments.push(attachment); + } + } + } + } + }; + + // Scan uploaded files + if source + .as_ref() + .map(|s| *s == FileSource::Upload) + .unwrap_or(true) + { + scan_dir(&get_uploaded_files_dir(), FileSource::Upload); + } + + // Scan generated files + if source + .as_ref() + .map(|s| *s == FileSource::Generated) + .unwrap_or(true) + { + scan_dir(&get_generated_files_dir(), FileSource::Generated); + } + + // Sort by upload date (newest first) and limit + attachments.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); + attachments.truncate(limit); + + Ok(attachments) +} + +/// Delete an agent file +#[tauri::command(rename_all = "snake_case")] +pub fn delete_agent_file(file_id: String) -> Result<(), String> { + // Try to find and delete in both directories + let uploaded_path = get_uploaded_files_dir().join(&file_id); + let uploaded_meta = get_uploaded_files_dir().join(format!("{}.meta.json", file_id)); + let generated_path = get_generated_files_dir().join(&file_id); + let generated_meta = get_generated_files_dir().join(format!("{}.meta.json", file_id)); + + let mut deleted = false; + + if uploaded_path.exists() { + fs::remove_file(&uploaded_path).ok(); + fs::remove_file(&uploaded_meta).ok(); + deleted = true; + } + + if generated_path.exists() { + fs::remove_file(&generated_path).ok(); + fs::remove_file(&generated_meta).ok(); + deleted = true; + } + + if !deleted { + return Err(format!("File not found: {}", file_id)); + } + + Ok(()) +} + +/// Validate a filesystem path for security +#[tauri::command(rename_all = "snake_case")] +pub fn validate_filesystem_path(path: String) -> Result { + let path_obj = Path::new(&path); + + // Check if path exists + if !path_obj.exists() { + return Ok(PathValidationResult { + valid: false, + sanitized_path: None, + error: Some("Path does not exist".to_string()), + within_sandbox: false, + }); + } + + // Check if it's a file + if !path_obj.is_file() { + return Ok(PathValidationResult { + valid: false, + sanitized_path: None, + error: Some("Path is not a file".to_string()), + within_sandbox: false, + }); + } + + // Get canonical path + let canonical = path_obj + .canonicalize() + .map_err(|e| format!("Failed to canonicalize path: {}", e))?; + + // Check if within data directory (sandbox) + let data_dir = get_data_dir_path(); + let within_sandbox = canonical.starts_with(&data_dir); + + Ok(PathValidationResult { + valid: true, + sanitized_path: Some(canonical.to_string_lossy().to_string()), + error: None, + within_sandbox, + }) +} + +/// Read a file from the filesystem and optionally create an attachment +#[tauri::command(rename_all = "snake_case")] +pub fn read_filesystem_file( + path: String, + auto_extract: bool, + max_size: Option, +) -> Result { + // Validate path + let validation = validate_filesystem_path(path.clone())?; + if !validation.valid { + return Err(validation + .error + .unwrap_or_else(|| "Invalid path".to_string())); + } + + let sanitized_path = validation.sanitized_path.unwrap_or(path); + let path_obj = Path::new(&sanitized_path); + + // Get file metadata + let metadata = + fs::metadata(&path_obj).map_err(|e| format!("Failed to get file metadata: {}", e))?; + + // Check size limit + let max_size = max_size.unwrap_or(FILE_SIZE_SMALL); + if metadata.len() > max_size { + return Err(format!( + "File too large: {} (max: {})", + format_file_size(metadata.len()), + format_file_size(max_size) + )); + } + + // Read file content + let content_bytes = fs::read(&path_obj).map_err(|e| format!("Failed to read file: {}", e))?; + + // Determine file properties + let filename = path_obj + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let category = FileCategory::from_extension(&filename); + + // Compute checksum + let checksum = compute_checksum(&content_bytes); + + // Extract content if requested and applicable + let (content, line_count) = if auto_extract && category.should_auto_extract() { + match String::from_utf8(content_bytes.clone()) { + Ok(text) => { + let lines = text.lines().count() as u32; + (Some(text), Some(lines)) + } + Err(_) => (Some("[Binary content]".to_string()), None), + } + } else { + (None, None) + }; + + let now = Utc::now().to_rfc3339(); + + // Create attachment (not saved to disk, just returned) + let attachment = FileAttachment { + id: Uuid::new_v4().to_string(), + source: FileSource::Filesystem, + original_name: filename.clone(), + stored_name: filename.clone(), + mime_type: "application/octet-stream".to_string(), + category, + size: metadata.len(), + stored_path: sanitized_path.clone(), + thumbnail_path: None, + content, + encoding: Some("utf-8".to_string()), + line_count, + checksum, + uploaded_at: now.clone(), + expires_at: None, + metadata: FileAttachmentMetadata { + upload_metadata: None, + generation_metadata: None, + filesystem_metadata: Some(FilesystemMetadata { + original_path: sanitized_path, + accessed_at: now, + auto_read: auto_extract, + }), + }, + }; + + Ok(attachment) +} diff --git a/src-tauri/src/commands/agent/commands.rs b/src-tauri/src/commands/agent/commands.rs new file mode 100644 index 0000000..b9089fb --- /dev/null +++ b/src-tauri/src/commands/agent/commands.rs @@ -0,0 +1,343 @@ +//! Command execution & approval + +use std::process::Command; + +use chrono::Utc; +use regex::Regex; +use rusqlite::params; +use uuid::Uuid; + +use super::{ + get_db_connection, get_settings, PENDING_COMMANDS, + AgentSettings, ApprovalMode, CommandExecutionResult, CommandStatus, PendingCommand, +}; + +/// Check if a command matches the whitelist patterns +fn is_command_whitelisted(command: &str, patterns: &[String]) -> bool { + for pattern in patterns { + if let Ok(re) = Regex::new(pattern) { + if re.is_match(command) { + return true; + } + } + } + false +} + +/// Execute a shell command +fn execute_shell_command(command: &str) -> Result { + #[cfg(windows)] + { + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", command]) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + Ok(CommandExecutionResult { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } + + #[cfg(not(windows))] + { + let output = Command::new("sh") + .args(["-c", command]) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + Ok(CommandExecutionResult { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +/// Execute a command directly (bypasses approval mode check) +/// Used by the frontend HITL flow after user has already approved +#[tauri::command] +pub fn execute_agent_command(command: String, reason: String) -> Result { + let result = execute_shell_command(&command)?; + + let pending = PendingCommand { + id: Uuid::new_v4().to_string(), + command, + reason, + created_at: Utc::now().to_rfc3339(), + status: if result.exit_code == 0 { + CommandStatus::Executed + } else { + CommandStatus::Failed + }, + output: Some(result.stdout), + error: if result.stderr.is_empty() { + None + } else { + Some(result.stderr) + }, + }; + + // Log to history + log_command_to_history(&pending)?; + + Ok(pending) +} + +/// Queue a command for approval +#[tauri::command] +pub fn queue_agent_command(command: String, reason: String) -> Result { + let settings = get_settings()?; + let agent_settings = &settings.agent; + + // Check approval mode + match agent_settings.approval_mode { + ApprovalMode::Yolo => { + // Execute immediately + let result = execute_shell_command(&command)?; + let pending = PendingCommand { + id: Uuid::new_v4().to_string(), + command, + reason, + created_at: Utc::now().to_rfc3339(), + status: if result.exit_code == 0 { + CommandStatus::Executed + } else { + CommandStatus::Failed + }, + output: Some(result.stdout), + error: if result.stderr.is_empty() { + None + } else { + Some(result.stderr) + }, + }; + + // Log to history + log_command_to_history(&pending)?; + + Ok(pending) + } + ApprovalMode::Whitelist => { + if is_command_whitelisted(&command, &agent_settings.whitelisted_commands) { + // Execute immediately + let result = execute_shell_command(&command)?; + let pending = PendingCommand { + id: Uuid::new_v4().to_string(), + command, + reason, + created_at: Utc::now().to_rfc3339(), + status: if result.exit_code == 0 { + CommandStatus::Executed + } else { + CommandStatus::Failed + }, + output: Some(result.stdout), + error: if result.stderr.is_empty() { + None + } else { + Some(result.stderr) + }, + }; + + log_command_to_history(&pending)?; + Ok(pending) + } else { + // Queue for approval + queue_for_approval(command, reason) + } + } + ApprovalMode::Always => { + // Always queue for approval + queue_for_approval(command, reason) + } + } +} + +fn queue_for_approval(command: String, reason: String) -> Result { + let pending = PendingCommand { + id: Uuid::new_v4().to_string(), + command, + reason, + created_at: Utc::now().to_rfc3339(), + status: CommandStatus::Pending, + output: None, + error: None, + }; + + let mut commands = PENDING_COMMANDS + .lock() + .map_err(|e| format!("Failed to lock pending commands: {}", e))?; + commands.push(pending.clone()); + + Ok(pending) +} + +fn log_command_to_history(cmd: &PendingCommand) -> Result<(), String> { + let conn = get_db_connection()?; + + let status_str = match cmd.status { + CommandStatus::Pending => "pending", + CommandStatus::Approved => "approved", + CommandStatus::Rejected => "rejected", + CommandStatus::Executed => "executed", + CommandStatus::Failed => "failed", + }; + + conn.execute( + "INSERT INTO command_history (id, command, reason, status, output, error, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + cmd.id, + cmd.command, + cmd.reason, + status_str, + cmd.output, + cmd.error, + cmd.created_at + ], + ) + .map_err(|e| format!("Failed to log command: {}", e))?; + + Ok(()) +} + +/// Get all pending commands +#[tauri::command] +pub fn get_pending_commands() -> Result, String> { + let commands = PENDING_COMMANDS + .lock() + .map_err(|e| format!("Failed to lock pending commands: {}", e))?; + Ok(commands.clone()) +} + +/// Clear all pending commands +#[tauri::command] +pub fn clear_pending_commands() -> Result<(), String> { + let mut commands = PENDING_COMMANDS + .lock() + .map_err(|e| format!("Failed to lock pending commands: {}", e))?; + commands.clear(); + Ok(()) +} + +/// Approve a pending command and execute it +#[tauri::command(rename_all = "snake_case")] +pub fn approve_command(command_id: String) -> Result { + let mut commands = PENDING_COMMANDS + .lock() + .map_err(|e| format!("Failed to lock pending commands: {}", e))?; + + let idx = commands + .iter() + .position(|c| c.id == command_id) + .ok_or_else(|| "Command not found".to_string())?; + + let mut cmd = commands.remove(idx); + + // Execute the command + let result = execute_shell_command(&cmd.command)?; + + cmd.status = if result.exit_code == 0 { + CommandStatus::Executed + } else { + CommandStatus::Failed + }; + cmd.output = Some(result.stdout); + cmd.error = if result.stderr.is_empty() { + None + } else { + Some(result.stderr) + }; + + // Log to history + log_command_to_history(&cmd)?; + + Ok(cmd) +} + +/// Reject a pending command +#[tauri::command(rename_all = "snake_case")] +pub fn reject_command(command_id: String) -> Result { + let mut commands = PENDING_COMMANDS + .lock() + .map_err(|e| format!("Failed to lock pending commands: {}", e))?; + + let idx = commands + .iter() + .position(|c| c.id == command_id) + .ok_or_else(|| "Command not found".to_string())?; + + let mut cmd = commands.remove(idx); + cmd.status = CommandStatus::Rejected; + + // Log to history + log_command_to_history(&cmd)?; + + Ok(cmd) +} + +/// Get agent settings +#[tauri::command] +pub fn get_agent_settings() -> Result { + let settings = get_settings()?; + Ok(settings.agent) +} + +/// Get command history +#[tauri::command] +pub fn get_command_history(limit: Option) -> Result, String> { + let conn = get_db_connection()?; + let limit = limit.unwrap_or(50); + + let mut stmt = conn + .prepare( + "SELECT id, command, reason, status, output, error, created_at + FROM command_history + ORDER BY created_at DESC + LIMIT ?1", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![limit as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + let mut history = Vec::new(); + for row in rows { + let (id, command, reason, status_str, output, error, created_at) = + row.map_err(|e| format!("Failed to read row: {}", e))?; + + let status = match status_str.as_str() { + "pending" => CommandStatus::Pending, + "approved" => CommandStatus::Approved, + "rejected" => CommandStatus::Rejected, + "executed" => CommandStatus::Executed, + "failed" => CommandStatus::Failed, + _ => CommandStatus::Pending, + }; + + history.push(PendingCommand { + id, + command, + reason: reason.unwrap_or_default(), + created_at, + status, + output, + error, + }); + } + + Ok(history) +} diff --git a/src-tauri/src/commands/agent/conversations.rs b/src-tauri/src/commands/agent/conversations.rs new file mode 100644 index 0000000..cb482e1 --- /dev/null +++ b/src-tauri/src/commands/agent/conversations.rs @@ -0,0 +1,200 @@ +//! Conversation persistence + +use chrono::Utc; +use rusqlite::params; +use uuid::Uuid; + +use super::{ + get_db_connection, Conversation, ConversationMessage, ConversationWithMessages, +}; + +/// Create a new conversation +#[tauri::command] +pub fn create_conversation(title: Option) -> Result { + let conn = get_db_connection()?; + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let conversation_title = title.unwrap_or_else(|| "New Chat".to_string()); + + conn.execute( + "INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![id, conversation_title, now, now], + ) + .map_err(|e| format!("Failed to create conversation: {}", e))?; + + Ok(Conversation { + id, + title: conversation_title, + created_at: now.clone(), + updated_at: now, + }) +} + +/// List all conversations +#[tauri::command] +pub fn list_conversations(limit: Option) -> Result, String> { + let conn = get_db_connection()?; + let limit_val = limit.unwrap_or(50) as i64; + + // Only show conversations that have at least one message (excludes empty "New Chat" stubs) + let mut stmt = conn + .prepare( + "SELECT c.id, c.title, c.created_at, c.updated_at + FROM conversations c + WHERE EXISTS (SELECT 1 FROM conversation_messages m WHERE m.conversation_id = c.id) + ORDER BY c.updated_at DESC + LIMIT ?1", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![limit_val], |row| { + Ok(Conversation { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + let mut conversations = Vec::new(); + for row in rows { + conversations.push(row.map_err(|e| format!("Failed to read row: {}", e))?); + } + + Ok(conversations) +} + +/// Get a conversation with its messages +#[tauri::command] +pub fn get_conversation(conversation_id: String) -> Result { + let conn = get_db_connection()?; + + // Get conversation + let conversation: Conversation = conn + .query_row( + "SELECT id, title, created_at, updated_at FROM conversations WHERE id = ?1", + params![conversation_id], + |row| { + Ok(Conversation { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .map_err(|e| format!("Conversation not found: {}", e))?; + + // Get messages + let mut stmt = conn + .prepare( + "SELECT id, conversation_id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = ?1 + ORDER BY created_at ASC", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![conversation_id], |row| { + Ok(ConversationMessage { + id: row.get(0)?, + conversation_id: row.get(1)?, + role: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + }) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + let mut messages = Vec::new(); + for row in rows { + messages.push(row.map_err(|e| format!("Failed to read row: {}", e))?); + } + + Ok(ConversationWithMessages { + conversation, + messages, + }) +} + +/// Save messages to a conversation (replaces existing messages) +#[tauri::command] +pub fn save_conversation_messages( + conversation_id: String, + messages: Vec, +) -> Result<(), String> { + let conn = get_db_connection()?; + let now = Utc::now().to_rfc3339(); + + // Delete existing messages for this conversation + conn.execute( + "DELETE FROM conversation_messages WHERE conversation_id = ?1", + params![conversation_id], + ) + .map_err(|e| format!("Failed to delete old messages: {}", e))?; + + // Insert new messages + for msg in messages { + conn.execute( + "INSERT INTO conversation_messages (id, conversation_id, role, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + msg.id, + conversation_id, + msg.role, + msg.content, + msg.created_at + ], + ) + .map_err(|e| format!("Failed to insert message: {}", e))?; + } + + // Update conversation's updated_at + conn.execute( + "UPDATE conversations SET updated_at = ?1 WHERE id = ?2", + params![now, conversation_id], + ) + .map_err(|e| format!("Failed to update conversation: {}", e))?; + + Ok(()) +} + +/// Update conversation title +#[tauri::command] +pub fn update_conversation_title(conversation_id: String, title: String) -> Result<(), String> { + let conn = get_db_connection()?; + let now = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE conversations SET title = ?1, updated_at = ?2 WHERE id = ?3", + params![title, now, conversation_id], + ) + .map_err(|e| format!("Failed to update conversation title: {}", e))?; + + Ok(()) +} + +/// Delete a conversation and its messages +#[tauri::command] +pub fn delete_conversation(conversation_id: String) -> Result<(), String> { + let conn = get_db_connection()?; + + // Delete messages first (in case foreign key cascade doesn't work) + conn.execute( + "DELETE FROM conversation_messages WHERE conversation_id = ?1", + params![conversation_id], + ) + .map_err(|e| format!("Failed to delete messages: {}", e))?; + + // Delete conversation + conn.execute( + "DELETE FROM conversations WHERE id = ?1", + params![conversation_id], + ) + .map_err(|e| format!("Failed to delete conversation: {}", e))?; + + Ok(()) +} diff --git a/src-tauri/src/commands/agent/files.rs b/src-tauri/src/commands/agent/files.rs new file mode 100644 index 0000000..9e6c67c --- /dev/null +++ b/src-tauri/src/commands/agent/files.rs @@ -0,0 +1,389 @@ +//! File operations, instruments, programs + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use regex::Regex; + +use super::{get_data_dir_path, FileEntry, Instrument}; + +/// Read file with optional line numbers and pagination +#[tauri::command(rename_all = "snake_case")] +pub fn agent_read_file( + path: String, + offset: Option, + limit: Option, + line_numbers: Option, +) -> Result { + let content = fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?; + + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + + let offset_val = offset.unwrap_or(0); + let limit_val = limit.unwrap_or(total_lines); + + let end = std::cmp::min(offset_val + limit_val, total_lines); + let selected: Vec<&str> = if offset_val < total_lines { + lines[offset_val..end].to_vec() + } else { + Vec::new() + }; + + let show_line_numbers = line_numbers.unwrap_or(true); + let formatted_content = if show_line_numbers { + selected + .iter() + .enumerate() + .map(|(idx, line)| format!("{:4}| {}", offset_val + idx + 1, line)) + .collect::>() + .join("\n") + } else { + selected.join("\n") + }; + + Ok(serde_json::json!({ + "content": formatted_content, + "total_lines": total_lines, + "has_more": end < total_lines, + })) +} + +/// Write to a file (requires approval in non-YOLO mode) +/// Creates parent directories if they don't exist +#[tauri::command] +pub fn agent_write_file(path: String, content: String) -> Result<(), String> { + let path_buf = PathBuf::from(&path); + + // Create parent directories if they don't exist + if let Some(parent) = path_buf.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent directories: {}", e))?; + } + } + + fs::write(&path, content).map_err(|e| format!("Failed to write file: {}", e)) +} + +#[tauri::command] +pub fn agent_list_dir(path: String) -> Result, String> { + let mut entries = Vec::new(); + for entry in fs::read_dir(&path).map_err(|e| format!("Failed to read dir: {}", e))? { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + let metadata = entry + .metadata() + .map_err(|e| format!("Failed to get metadata: {}", e))?; + + entries.push(FileEntry { + name: path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + path: path.to_string_lossy().to_string(), + is_dir: path.is_dir(), + size: metadata.len(), + }); + } + Ok(entries) +} + +#[tauri::command] +pub fn agent_move_file(src: String, dest: String) -> Result<(), String> { + fs::rename(src, dest).map_err(|e| format!("Failed to move file: {}", e)) +} + +#[tauri::command] +pub fn agent_copy_file(src: String, dest: String) -> Result<(), String> { + fs::copy(src, dest) + .map(|_| ()) + .map_err(|e| format!("Failed to copy file: {}", e)) +} + +/// List instruments (custom scripts) +#[tauri::command] +pub fn list_instruments() -> Result, String> { + let instruments_dir = get_data_dir_path().join("instruments"); + if !instruments_dir.exists() { + // Create if it doesn't exist + fs::create_dir_all(&instruments_dir).ok(); + return Ok(vec![]); + } + + let mut instruments = Vec::new(); + if let Ok(entries) = fs::read_dir(instruments_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if ["ps1", "bat", "cmd", "exe", "py", "js"].contains(&ext) { + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + instruments.push(Instrument { + name, + description: format!("Custom instrument ({})", ext), + path: path.to_string_lossy().to_string(), + extension: ext.to_string(), + }); + } + } + } + } + } + Ok(instruments) +} + +fn collect_exes_recursive(dir: &std::path::Path, root: &std::path::Path, acc: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + collect_exes_recursive(&p, root, acc); + } else if p.extension().map(|e| e == "exe").unwrap_or(false) { + if let Ok(rel) = p.strip_prefix(root) { + acc.push(rel.to_string_lossy().replace('\\', "/")); + } + } + } + } +} + +/// List programs in the programs folder +#[tauri::command] +pub fn list_agent_programs() -> Result>, String> { + let programs_dir = get_data_dir_path().join("programs"); + + if !programs_dir.exists() { + return Ok(vec![]); + } + + let mut programs = Vec::new(); + + for entry in + fs::read_dir(&programs_dir).map_err(|e| format!("Failed to read programs dir: {}", e))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let mut exes = Vec::new(); + collect_exes_recursive(&path, &path, &mut exes); + + let mut info = HashMap::new(); + info.insert("name".to_string(), name); + info.insert("path".to_string(), path.to_string_lossy().to_string()); + info.insert("executables".to_string(), exes.join(", ")); + + programs.push(info); + } + } + + Ok(programs) +} + +/// Find executables by name/keyword across the programs folder and optionally system PATH +#[tauri::command] +pub fn agent_find_exe(query: String, search_path: Option) -> Result, String> { + let programs_dir = get_data_dir_path().join("programs"); + let query_lower = query.to_lowercase(); + let mut matches: Vec = Vec::new(); + + fn walk(dir: &std::path::Path, query: &str, results: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, query, results); + } else if p.extension().map(|e| e == "exe").unwrap_or(false) { + let stem = p + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + if stem.contains(query) { + results.push(p.to_string_lossy().to_string()); + } + } + } + } + } + + if programs_dir.exists() { + walk(&programs_dir, &query_lower, &mut matches); + } + + if search_path.unwrap_or(false) { + if let Ok(output) = std::process::Command::new("cmd") + .args(["/C", &format!("where.exe {}", query)]) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let t = line.trim().to_string(); + if !t.is_empty() { + matches.push(t); + } + } + } + } + + Ok(matches) +} + +/// Edit file by replacing old_string with new_string +#[tauri::command(rename_all = "snake_case")] +pub fn agent_edit_file( + path: String, + old_string: String, + new_string: String, + all: Option, +) -> Result { + let text = fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?; + + let replace_all = all.unwrap_or(false); + + if !text.contains(&old_string) { + return Ok(serde_json::json!({ + "status": "error", + "replacements": 0, + "message": "old_string not found in file", + })); + } + + let count = text.matches(&old_string).count(); + if !replace_all && count > 1 { + return Ok(serde_json::json!({ + "status": "error", + "replacements": 0, + "message": format!("old_string appears {} times, must be unique (use all=true)", count), + })); + } + + let replacement = if replace_all { + text.replace(&old_string, &new_string) + } else { + text.replacen(&old_string, &new_string, 1) + }; + + fs::write(&path, replacement).map_err(|e| format!("Failed to write file: {}", e))?; + + let replacements = if replace_all { count } else { 1 }; + + Ok(serde_json::json!({ + "status": "success", + "replacements": replacements, + "message": format!("Successfully made {} replacement{}", replacements, if replacements > 1 { "s" } else { "" }), + })) +} + +/// Grep - search for regex pattern across files +#[tauri::command(rename_all = "snake_case")] +pub fn agent_grep( + pattern: String, + path: Option, + file_pattern: Option, + max_results: Option, +) -> Result, String> { + let regex = Regex::new(&pattern).map_err(|e| format!("Invalid regex pattern: {}", e))?; + + let base_path = path.unwrap_or_else(|| ".".to_string()); + let max = max_results.unwrap_or(50); + let glob_pat = file_pattern.unwrap_or_else(|| "*".to_string()); + + let mut results = Vec::new(); + + // Build glob pattern + let full_pattern = format!("{}/**/{}", base_path, glob_pat); + + for entry in glob::glob(&full_pattern) + .map_err(|e| format!("Invalid glob pattern: {}", e))? + .flatten() + { + if !entry.is_file() { + continue; + } + + // Try to read as text + if let Ok(content) = fs::read_to_string(&entry) { + for (line_num, line) in content.lines().enumerate() { + if regex.is_match(line) { + results.push(serde_json::json!({ + "file": entry.to_string_lossy().to_string(), + "line": line_num + 1, + "content": line.to_string(), + })); + + if results.len() >= max { + return Ok(results); + } + } + } + } + } + + Ok(results) +} + +/// Glob - find files matching pattern, sorted by mtime +#[tauri::command(rename_all = "snake_case")] +pub fn agent_glob( + pattern: String, + path: Option, + limit: Option, +) -> Result, String> { + let base_path = path.unwrap_or_else(|| ".".to_string()); + let max = limit.unwrap_or(100); + + let full_pattern = format!("{}/{}", base_path, pattern); + + let mut files: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new(); + + for entry in glob::glob(&full_pattern) + .map_err(|e| format!("Invalid glob pattern: {}", e))? + .flatten() + { + if let Ok(metadata) = fs::metadata(&entry) { + let mtime = metadata + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let size = metadata.len(); + files.push((entry, mtime, size)); + } + } + + // Sort by modification time (newest first) + files.sort_by(|a, b| b.1.cmp(&a.1)); + + let results: Vec = files + .into_iter() + .take(max) + .map(|(path, mtime, size)| { + let modified_str = chrono::DateTime::::from(mtime) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + serde_json::json!({ + "path": path.to_string_lossy().to_string(), + "name": path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + "modified": modified_str, + "size": size, + }) + }) + .collect(); + + Ok(results) +} diff --git a/src-tauri/src/commands/agent/memory.rs b/src-tauri/src/commands/agent/memory.rs new file mode 100644 index 0000000..60d6b14 --- /dev/null +++ b/src-tauri/src/commands/agent/memory.rs @@ -0,0 +1,892 @@ +//! Memory CRUD + vector search + +use chrono::Utc; +use rusqlite::params; +use serde_json::json; +use uuid::Uuid; + +use super::{get_current_machine_id, get_db_connection, Memory, MemoryScope, MemoryType}; + +/// Save a memory entry +/// +/// The `scope` parameter determines memory portability: +/// - "global": Travels with the technician across machines (solutions, knowledge, behaviors) +/// - "machine": Specific to current machine (system info, local context) +/// +/// If scope is not provided, it defaults based on memory type: +/// - system, conversation, summary -> machine scope +/// - fact, solution, knowledge, behavior, instruction -> global scope +#[tauri::command] +pub fn save_memory( + memory_type: String, + content: String, + metadata: Option, + embedding: Option>, + importance: Option, + source_conversation_id: Option, + scope: Option, +) -> Result { + let conn = get_db_connection()?; + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let importance_val = importance.unwrap_or(50); + + // Clone metadata for later use + let metadata_for_return = metadata.clone().unwrap_or(json!({})); + + let meta_str = metadata + .map(|m| serde_json::to_string(&m).unwrap_or_default()) + .unwrap_or_else(|| "{}".to_string()); + + // Convert embedding to bytes if provided + let embedding_bytes: Option> = + embedding.map(|e| e.iter().flat_map(|f| f.to_le_bytes().to_vec()).collect()); + + // Determine scope - use provided value or default based on memory type + let mem_type = MemoryType::from_str(&memory_type); + let memory_scope = scope + .map(|s| MemoryScope::from_str(&s)) + .unwrap_or_else(|| MemoryScope::default_for_type(&mem_type)); + let scope_str = memory_scope.as_str().to_string(); + + // Only set machine_id for machine-scoped memories + let machine_id = if memory_scope == MemoryScope::Machine { + Some(get_current_machine_id()) + } else { + None + }; + + conn.execute( + "INSERT INTO memories (id, type, content, embedding, metadata, created_at, updated_at, importance, access_count, last_accessed, source_conversation_id, scope, machine_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + id, + memory_type, + content, + embedding_bytes, + meta_str, + now, + now, + importance_val, + 0, + Option::::None, + source_conversation_id, + scope_str, + machine_id + ], + ) + .map_err(|e| format!("Failed to save memory: {}", e))?; + + Ok(Memory { + id, + memory_type: mem_type, + content, + metadata: metadata_for_return, + created_at: now.clone(), + updated_at: now, + importance: importance_val, + access_count: 0, + last_accessed: None, + source_conversation_id, + scope: memory_scope, + machine_id, + }) +} + +/// Helper to convert row data to Memory +pub(super) fn row_to_memory( + id: String, + type_str: String, + content: String, + meta_str: String, + created_at: String, + updated_at: String, + importance: i32, + access_count: i32, + last_accessed: Option, + source_conversation_id: Option, + scope_str: Option, + machine_id: Option, +) -> Memory { + let mem_type = MemoryType::from_str(&type_str); + let metadata: serde_json::Value = serde_json::from_str(&meta_str).unwrap_or(json!({})); + let scope = MemoryScope::from_str(&scope_str.unwrap_or_else(|| "global".to_string())); + + Memory { + id, + memory_type: mem_type, + content, + metadata, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + } +} + +/// Search memories by text (simple substring search) +/// +/// Respects memory scope: +/// - Global memories are always returned +/// - Machine-scoped memories only returned if they match current machine +#[tauri::command] +pub fn search_memories( + query: String, + memory_type: Option, + limit: Option, +) -> Result, String> { + let conn = get_db_connection()?; + let current_machine = get_current_machine_id(); + + let limit_val = limit.unwrap_or(10) as i64; + let search_pattern = format!("%{}%", query.to_lowercase()); + + let mut memories = Vec::new(); + + if let Some(mem_type) = memory_type { + // With memory type filter: ?1 = pattern, ?2 = type, ?3 = machine_id, ?4 = limit + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories + WHERE LOWER(content) LIKE ?1 AND type = ?2 AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?3)) + ORDER BY importance DESC, updated_at DESC + LIMIT ?4", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map( + params![search_pattern, mem_type, current_machine, limit_val], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }, + ) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + memories.push(row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + )); + } + } else { + // Without memory type filter: ?1 = pattern, ?2 = machine_id, ?3 = limit + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories + WHERE LOWER(content) LIKE ?1 AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?2)) + ORDER BY importance DESC, updated_at DESC + LIMIT ?3", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![search_pattern, current_machine, limit_val], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + memories.push(row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + )); + } + } + + Ok(memories) +} + +/// Get all memories +/// +/// Respects memory scope: +/// - Global memories are always returned +/// - Machine-scoped memories only returned if they match current machine +#[tauri::command] +pub fn get_all_memories( + memory_type: Option, + limit: Option, +) -> Result, String> { + let conn = get_db_connection()?; + let current_machine = get_current_machine_id(); + let limit_val = limit.unwrap_or(100) as i64; + + let mut memories = Vec::new(); + + if let Some(mem_type) = memory_type { + // With memory type filter: ?1 = type, ?2 = machine_id, ?3 = limit + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories + WHERE type = ?1 AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?2)) + ORDER BY importance DESC, updated_at DESC + LIMIT ?3", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![mem_type, current_machine, limit_val], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + memories.push(row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + )); + } + } else { + // Without memory type filter: ?1 = machine_id, ?2 = limit + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories + WHERE (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?1)) + ORDER BY importance DESC, updated_at DESC + LIMIT ?2", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![current_machine, limit_val], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + memories.push(row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + )); + } + } + + Ok(memories) +} + +/// Delete a memory entry +#[tauri::command] +pub fn delete_memory(memory_id: String) -> Result<(), String> { + let conn = get_db_connection()?; + + conn.execute("DELETE FROM memories WHERE id = ?1", params![memory_id]) + .map_err(|e| format!("Failed to delete memory: {}", e))?; + + Ok(()) +} + +/// Clear all memories +#[tauri::command] +pub fn clear_all_memories() -> Result<(), String> { + let conn = get_db_connection()?; + + conn.execute("DELETE FROM memories", []) + .map_err(|e| format!("Failed to clear memories: {}", e))?; + + Ok(()) +} + +/// Update an existing memory entry +#[tauri::command] +pub fn update_memory( + memory_id: String, + content: Option, + metadata: Option, + importance: Option, +) -> Result { + let conn = get_db_connection()?; + let now = Utc::now().to_rfc3339(); + + // Execute update based on provided fields + match (content.as_ref(), metadata.as_ref(), importance) { + (Some(c), Some(m), Some(i)) => { + let meta_str = serde_json::to_string(m).unwrap_or_default(); + conn.execute( + "UPDATE memories SET updated_at = ?1, content = ?2, metadata = ?3, importance = ?4 WHERE id = ?5", + params![now, c, meta_str, i, memory_id], + ) + } + (Some(c), Some(m), None) => { + let meta_str = serde_json::to_string(m).unwrap_or_default(); + conn.execute( + "UPDATE memories SET updated_at = ?1, content = ?2, metadata = ?3 WHERE id = ?4", + params![now, c, meta_str, memory_id], + ) + } + (Some(c), None, Some(i)) => conn.execute( + "UPDATE memories SET updated_at = ?1, content = ?2, importance = ?3 WHERE id = ?4", + params![now, c, i, memory_id], + ), + (Some(c), None, None) => conn.execute( + "UPDATE memories SET updated_at = ?1, content = ?2 WHERE id = ?3", + params![now, c, memory_id], + ), + (None, Some(m), Some(i)) => { + let meta_str = serde_json::to_string(m).unwrap_or_default(); + conn.execute( + "UPDATE memories SET updated_at = ?1, metadata = ?2, importance = ?3 WHERE id = ?4", + params![now, meta_str, i, memory_id], + ) + } + (None, Some(m), None) => { + let meta_str = serde_json::to_string(m).unwrap_or_default(); + conn.execute( + "UPDATE memories SET updated_at = ?1, metadata = ?2 WHERE id = ?3", + params![now, meta_str, memory_id], + ) + } + (None, None, Some(i)) => conn.execute( + "UPDATE memories SET updated_at = ?1, importance = ?2 WHERE id = ?3", + params![now, i, memory_id], + ), + (None, None, None) => conn.execute( + "UPDATE memories SET updated_at = ?1 WHERE id = ?2", + params![now, memory_id], + ), + } + .map_err(|e| format!("Failed to update memory: {}", e))?; + + // Fetch and return updated memory + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories WHERE id = ?1", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let memory = stmt + .query_row(params![memory_id], |row| { + Ok(row_to_memory( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + row.get(11)?, + )) + }) + .map_err(|e| format!("Memory not found: {}", e))?; + + Ok(memory) +} + +/// Delete multiple memories by IDs +#[tauri::command] +pub fn bulk_delete_memories(memory_ids: Vec) -> Result { + let conn = get_db_connection()?; + + let mut deleted = 0; + for id in memory_ids { + let result = conn.execute("DELETE FROM memories WHERE id = ?1", params![id]); + if result.is_ok() { + deleted += 1; + } + } + + Ok(deleted) +} + +/// Get memory statistics +#[tauri::command] +pub fn get_memory_stats() -> Result { + let conn = get_db_connection()?; + + // Get total count + let total_count: i64 = conn + .query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0)) + .unwrap_or(0); + + // Get count by type + let mut stmt = conn + .prepare("SELECT type, COUNT(*) FROM memories GROUP BY type") + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let mut by_type = std::collections::HashMap::new(); + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let (type_str, count) = row.map_err(|e| format!("Failed to read row: {}", e))?; + by_type.insert(type_str, count); + } + + // Estimate total size (content length) + let total_size_bytes: i64 = conn + .query_row( + "SELECT COALESCE(SUM(LENGTH(content)), 0) FROM memories", + [], + |row| row.get(0), + ) + .unwrap_or(0); + + Ok(crate::types::MemoryStats { + total_count, + by_type, + total_size_bytes, + }) +} + +/// Increment memory access count and update last_accessed timestamp +#[tauri::command] +pub fn increment_memory_access(memory_id: String) -> Result<(), String> { + let conn = get_db_connection()?; + let now = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE memories SET access_count = COALESCE(access_count, 0) + 1, last_accessed = ?1 WHERE id = ?2", + params![now, memory_id], + ) + .map_err(|e| format!("Failed to increment access count: {}", e))?; + + Ok(()) +} + +/// Get recently accessed memories +/// +/// Respects memory scope: +/// - Global memories are always returned +/// - Machine-scoped memories only returned if they match current machine +#[tauri::command] +pub fn get_recent_memories(limit: Option) -> Result, String> { + let conn = get_db_connection()?; + let current_machine = get_current_machine_id(); + let limit_val = limit.unwrap_or(10) as i64; + + // ?1 = machine_id, ?2 = limit + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + scope, machine_id + FROM memories + WHERE last_accessed IS NOT NULL AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?1)) + ORDER BY last_accessed DESC + LIMIT ?2", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![current_machine, limit_val], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Option>(10)?, + row.get::<_, Option>(11)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + let mut memories = Vec::new(); + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + memories.push(row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + )); + } + + Ok(memories) +} + +// ============================================================================= +// Vector Search +// ============================================================================= + +fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { + let dot_product: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum(); + let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); + let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); + if norm_a == 0.0 || norm_b == 0.0 { + 0.0 + } else { + dot_product / (norm_a * norm_b) + } +} + +/// Search memories using vector similarity +/// +/// Respects memory scope: +/// - Global memories are always returned +/// - Machine-scoped memories only returned if they match current machine +#[tauri::command] +pub fn search_memories_vector( + embedding: Vec, + memory_type: Option, + limit: Option, +) -> Result, String> { + let conn = get_db_connection()?; + let current_machine = get_current_machine_id(); + let limit_val = limit.unwrap_or(5); + + let mut scored_memories = Vec::new(); + + // Fetch all memories with embeddings based on type filter + if let Some(ref mem_type) = memory_type { + // With memory type filter: ?1 = machine_id, ?2 = type + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + embedding, scope, machine_id + FROM memories + WHERE embedding IS NOT NULL AND type = ?2 AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?1))", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![current_machine, mem_type], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Vec>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + embedding_bytes, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + + let stored_embedding: Vec = embedding_bytes + .chunks(4) + .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap())) + .collect(); + + if stored_embedding.len() == embedding.len() { + let score = cosine_similarity(&embedding, &stored_embedding); + scored_memories.push(( + score, + row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ), + )); + } + } + } else { + // Without memory type filter: ?1 = machine_id + let mut stmt = conn + .prepare( + "SELECT id, type, content, metadata, created_at, updated_at, + COALESCE(importance, 50), COALESCE(access_count, 0), last_accessed, source_conversation_id, + embedding, scope, machine_id + FROM memories + WHERE embedding IS NOT NULL AND (COALESCE(scope, 'global') = 'global' OR (scope = 'machine' AND machine_id = ?1))", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map(params![current_machine], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i32>(6)?, + row.get::<_, i32>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, Vec>(10)?, + row.get::<_, Option>(11)?, + row.get::<_, Option>(12)?, + )) + }) + .map_err(|e| format!("Failed to execute query: {}", e))?; + + for row in rows { + let ( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + embedding_bytes, + scope, + machine_id, + ) = row.map_err(|e| format!("Failed to read row: {}", e))?; + + let stored_embedding: Vec = embedding_bytes + .chunks(4) + .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap())) + .collect(); + + if stored_embedding.len() == embedding.len() { + let score = cosine_similarity(&embedding, &stored_embedding); + scored_memories.push(( + score, + row_to_memory( + id, + type_str, + content, + meta_str, + created_at, + updated_at, + importance, + access_count, + last_accessed, + source_conversation_id, + scope, + machine_id, + ), + )); + } + } + } + + // Sort by score descending + scored_memories.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + // Return top K + Ok(scored_memories + .into_iter() + .take(limit_val) + .map(|(_, m)| m) + .collect()) +} diff --git a/src-tauri/src/commands/agent/mod.rs b/src-tauri/src/commands/agent/mod.rs new file mode 100644 index 0000000..ebcd68c --- /dev/null +++ b/src-tauri/src/commands/agent/mod.rs @@ -0,0 +1,231 @@ +//! Agent commands +//! +//! Tauri commands for the agentic AI system including command execution, +//! memory management, and search functionality. + +pub mod attachments; +pub mod commands; +pub mod conversations; +pub mod files; +pub mod memory; +pub mod search; + +pub use attachments::*; +pub use commands::*; +pub use conversations::*; +pub use files::*; +pub use memory::*; +pub use search::*; + +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use rusqlite::Connection; + +pub(super) use super::data_dir::get_data_dir_path; +pub(super) use super::settings::get_settings; +use crate::types::{ + AgentSettings, ApprovalMode, CommandExecutionResult, CommandStatus, Conversation, + ConversationMessage, ConversationWithMessages, Memory, MemoryScope, MemoryType, PendingCommand, + SearchResult, +}; + +#[derive(serde::Serialize)] +pub struct FileEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: u64, +} + +#[derive(serde::Serialize)] +pub struct Instrument { + pub name: String, + pub description: String, + pub path: String, + pub extension: String, +} + +// ============================================================================= +// Global State +// ============================================================================= + +/// Pending commands awaiting approval +pub(super) static PENDING_COMMANDS: Mutex> = Mutex::new(Vec::new()); + +// ============================================================================= +// Machine Identification +// ============================================================================= + +/// Get a unique identifier for the current machine +/// Uses computer name as primary identifier, which is human-readable +/// and consistent across reboots +pub(super) fn get_current_machine_id() -> String { + // Use the COMPUTERNAME environment variable on Windows + std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .or_else(|_| { + gethostname::gethostname() + .into_string() + .map_err(|_| std::env::VarError::NotPresent) + }) + .unwrap_or_else(|_| "unknown-machine".to_string()) +} + +/// Get the current machine identifier (exposed to frontend) +#[tauri::command] +pub fn get_machine_id() -> Result { + Ok(get_current_machine_id()) +} + +// ============================================================================= +// Database Helpers +// ============================================================================= + +pub(super) fn get_agent_dir() -> PathBuf { + get_data_dir_path().join("agent") +} + +pub(super) fn get_memory_db_path() -> PathBuf { + get_agent_dir().join("memory.db") +} + +pub(super) fn ensure_agent_dir() -> Result<(), String> { + let dir = get_agent_dir(); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create agent directory: {}", e))?; + Ok(()) +} + +pub(super) fn get_db_connection() -> Result { + ensure_agent_dir()?; + let path = get_memory_db_path(); + let conn = + Connection::open(&path).map_err(|e| format!("Failed to open memory database: {}", e))?; + + // Initialize tables if they don't exist + conn.execute( + "CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + content TEXT NOT NULL, + embedding BLOB, + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + importance INTEGER DEFAULT 50, + access_count INTEGER DEFAULT 0, + last_accessed TEXT, + source_conversation_id TEXT, + scope TEXT DEFAULT 'global', + machine_id TEXT + )", + [], + ) + .map_err(|e| format!("Failed to create memories table: {}", e))?; + + // Migration: Add new columns if they don't exist (for existing databases) + let _ = conn.execute( + "ALTER TABLE memories ADD COLUMN importance INTEGER DEFAULT 50", + [], + ); + let _ = conn.execute( + "ALTER TABLE memories ADD COLUMN access_count INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute("ALTER TABLE memories ADD COLUMN last_accessed TEXT", []); + let _ = conn.execute( + "ALTER TABLE memories ADD COLUMN source_conversation_id TEXT", + [], + ); + // Migration: Add scope and machine_id for portable memory system + let _ = conn.execute( + "ALTER TABLE memories ADD COLUMN scope TEXT DEFAULT 'global'", + [], + ); + let _ = conn.execute("ALTER TABLE memories ADD COLUMN machine_id TEXT", []); + + conn.execute( + "CREATE TABLE IF NOT EXISTS command_history ( + id TEXT PRIMARY KEY, + command TEXT NOT NULL, + reason TEXT, + status TEXT NOT NULL, + output TEXT, + error TEXT, + created_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create command_history table: {}", e))?; + + // Create index for faster text search + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)", + [], + ) + .map_err(|e| format!("Failed to create index: {}", e))?; + + // Create index for importance-based queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC)", + [], + ) + .map_err(|e| format!("Failed to create importance index: {}", e))?; + + // Create index for access count queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_memories_access_count ON memories(access_count DESC)", + [], + ) + .map_err(|e| format!("Failed to create access count index: {}", e))?; + + // Create index for scope-based queries (portable memory system) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope)", + [], + ) + .map_err(|e| format!("Failed to create scope index: {}", e))?; + + // Create index for machine_id queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_memories_machine_id ON memories(machine_id)", + [], + ) + .map_err(|e| format!("Failed to create machine_id index: {}", e))?; + + // Conversations table for chat persistence + conn.execute( + "CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create conversations table: {}", e))?; + + // Messages within conversations + conn.execute( + "CREATE TABLE IF NOT EXISTS conversation_messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| format!("Failed to create conversation_messages table: {}", e))?; + + // Index for faster message queries + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_conversation_messages_conv_id ON conversation_messages(conversation_id)", + [], + ) + .map_err(|e| format!("Failed to create conversation messages index: {}", e))?; + + Ok(conn) +} diff --git a/src-tauri/src/commands/agent/search.rs b/src-tauri/src/commands/agent/search.rs new file mode 100644 index 0000000..c8dfcf7 --- /dev/null +++ b/src-tauri/src/commands/agent/search.rs @@ -0,0 +1,95 @@ +//! Web search operations + +use serde_json::json; + +use super::SearchResult; + +/// Search the web using Tavily +#[tauri::command] +pub async fn search_tavily(query: String, api_key: String) -> Result, String> { + let client = reqwest::Client::new(); + + let response = client + .post("https://api.tavily.com/search") + .header("Content-Type", "application/json") + .json(&json!({ + "api_key": api_key, + "query": query, + "search_depth": "basic", + "include_answer": false, + "include_images": false, + "max_results": 5 + })) + .send() + .await + .map_err(|e| format!("Failed to send request: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Tavily API error: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let results = data["results"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .map(|r| SearchResult { + title: r["title"].as_str().unwrap_or("").to_string(), + url: r["url"].as_str().unwrap_or("").to_string(), + snippet: r["content"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + }) + .collect(); + + Ok(results) +} + +/// Search the web using SearXNG +#[tauri::command] +pub async fn search_searxng( + query: String, + instance_url: String, +) -> Result, String> { + let client = reqwest::Client::new(); + + let url = format!( + "{}/search?q={}&format=json", + instance_url.trim_end_matches('/'), + urlencoding::encode(&query) + ); + + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to send request: {}", e))?; + + if !response.status().is_success() { + return Err(format!("SearXNG error: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let results = data["results"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .take(5) + .map(|r| SearchResult { + title: r["title"].as_str().unwrap_or("").to_string(), + url: r["url"].as_str().unwrap_or("").to_string(), + snippet: r["content"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + }) + .collect(); + + Ok(results) +} diff --git a/src-tauri/src/commands/bluescreen.rs b/src-tauri/src/commands/bluescreen.rs index 4601a9f..2a2a203 100644 --- a/src-tauri/src/commands/bluescreen.rs +++ b/src-tauri/src/commands/bluescreen.rs @@ -395,7 +395,7 @@ pub async fn get_bsod_details(dump_path: String) -> Result } /// Get crash info from Windows Event Log -async fn get_crash_info_from_events(crash_time: &str) -> (String, Option, Vec) { +async fn get_crash_info_from_events(_crash_time: &str) -> (String, Option, Vec) { let output = Command::new("powershell") .args([ "-NoProfile", diff --git a/src-tauri/src/commands/data_dir.rs b/src-tauri/src/commands/data_dir.rs index 607d668..28c2514 100644 --- a/src-tauri/src/commands/data_dir.rs +++ b/src-tauri/src/commands/data_dir.rs @@ -41,7 +41,7 @@ pub fn ensure_data_dir() -> Result<(), String> { let data_dir = get_data_dir_path(); // Create main data directory and subdirectories - let subdirs = ["programs", "logs", "reports", "scripts"]; + let subdirs = ["programs", "logs", "reports", "scripts", "agent"]; for subdir in subdirs { let path = data_dir.join(subdir); diff --git a/src-tauri/src/commands/disk_health.rs b/src-tauri/src/commands/disk_health.rs new file mode 100644 index 0000000..fc8aa39 --- /dev/null +++ b/src-tauri/src/commands/disk_health.rs @@ -0,0 +1,318 @@ +//! Disk health commands +//! +//! Retrieves S.M.A.R.T. disk health data using smartctl. +//! Falls back gracefully if smartctl is not installed. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +use crate::commands::get_program_exe_path; + +// ============================================================================ +// Types +// ============================================================================ + +/// A single S.M.A.R.T. attribute +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SmartAttribute { + pub id: u32, + pub name: String, + pub value: u32, + pub worst: u32, + pub threshold: u32, + pub raw_value: String, +} + +/// Health information for a single disk +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiskHealthInfo { + pub device: String, + pub model: String, + pub serial: String, + pub firmware: String, + pub health_passed: bool, + pub temperature_c: Option, + pub power_on_hours: Option, + pub reallocated_sectors: Option, + pub pending_sectors: Option, + pub crc_errors: Option, + pub wear_leveling_pct: Option, + pub attributes: Vec, +} + +/// Response from get_disk_health +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiskHealthResponse { + pub disks: Vec, + pub smartctl_found: bool, + pub error: Option, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Find smartctl executable using the centralized program resolver. +/// This checks settings overrides and recursively searches the data/programs folder, +/// matching the same logic used by the Programs page and smartctl service. +fn find_smartctl() -> Option { + // Use the centralized program resolver (checks overrides + recursive data/programs search) + if let Ok(Some(path)) = get_program_exe_path("smartctl".to_string()) { + return Some(path); + } + + // Fallback: check PATH and common install locations + if let Ok(output) = Command::new("where").arg("smartctl").output() { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path.is_empty() { + return Some(path); + } + } + } + + let common_paths = [ + r"C:\Program Files\smartmontools\bin\smartctl.exe", + r"C:\Program Files (x86)\smartmontools\bin\smartctl.exe", + ]; + for path in &common_paths { + if std::path::Path::new(path).exists() { + return Some(path.to_string()); + } + } + + None +} + +/// List physical drives on Windows using wmic +fn list_physical_drives() -> Vec { + let output = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "Get-PhysicalDisk | Select-Object -ExpandProperty DeviceId | Sort-Object", + ]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let text = String::from_utf8_lossy(&out.stdout); + text.lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + None + } else { + // Convert device ID to smartctl format: /dev/pd{N} + Some(format!("/dev/pd{}", trimmed)) + } + }) + .collect() + } + _ => { + // Fallback: try first 4 drives + (0..4).map(|i| format!("/dev/pd{}", i)).collect() + } + } +} + +/// Parse smartctl JSON output for a single drive +fn parse_smartctl_json(json_str: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(json_str).ok()?; + + // Check if smartctl returned valid data + let exit_status = json.get("smartctl")?.get("exit_status")?.as_u64()?; + // Bits 0-1 indicate command failure, allow other bits (health warnings etc.) + if exit_status & 0x03 != 0 { + return None; + } + + let device = json + .get("device")? + .get("name")? + .as_str() + .unwrap_or("unknown") + .to_string(); + + let model = json + .get("model_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Model") + .to_string(); + + let serial = json + .get("serial_number") + .and_then(|v| v.as_str()) + .unwrap_or("N/A") + .to_string(); + + let firmware = json + .get("firmware_version") + .and_then(|v| v.as_str()) + .unwrap_or("N/A") + .to_string(); + + let health_passed = json + .get("smart_status") + .and_then(|s| s.get("passed")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let temperature_c = json + .get("temperature") + .and_then(|t| t.get("current")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + + let power_on_hours = json + .get("power_on_time") + .and_then(|t| t.get("hours")) + .and_then(|v| v.as_u64()); + + // Parse ATA SMART attributes + let mut attributes = Vec::new(); + let mut reallocated_sectors: Option = None; + let mut pending_sectors: Option = None; + let mut crc_errors: Option = None; + let mut wear_leveling_pct: Option = None; + + if let Some(table) = json + .get("ata_smart_attributes") + .and_then(|a| a.get("table")) + .and_then(|t| t.as_array()) + { + for attr in table { + let id = attr.get("id").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let name = attr + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let value = attr.get("value").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let worst = attr.get("worst").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let thresh = attr.get("thresh").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + + let raw_value = attr + .get("raw") + .and_then(|r| r.get("string")) + .and_then(|v| v.as_str()) + .or_else(|| { + attr.get("raw") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_u64()) + .map(|_| "") + }) + .unwrap_or("0"); + + let raw_num = attr + .get("raw") + .and_then(|r| r.get("value")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + // Extract key metrics by attribute ID + match id { + 5 => reallocated_sectors = Some(raw_num), // Reallocated Sector Count + 197 => pending_sectors = Some(raw_num), // Current Pending Sector Count + 199 => crc_errors = Some(raw_num), // UDMA CRC Error Count + 177 | 231 => wear_leveling_pct = Some(value as u8), // Wear Leveling Count / SSD Life Left + _ => {} + } + + attributes.push(SmartAttribute { + id, + name, + value, + worst, + threshold: thresh, + raw_value: raw_value.to_string(), + }); + } + } + + // Also check NVMe health info + if let Some(nvme_health) = json.get("nvme_smart_health_information_log") { + if wear_leveling_pct.is_none() { + if let Some(pct_used) = nvme_health.get("percentage_used").and_then(|v| v.as_u64()) { + wear_leveling_pct = Some((100u64.saturating_sub(pct_used)) as u8); + } + } + } + + Some(DiskHealthInfo { + device, + model, + serial, + firmware, + health_passed, + temperature_c, + power_on_hours, + reallocated_sectors, + pending_sectors, + crc_errors, + wear_leveling_pct, + attributes, + }) +} + +// ============================================================================ +// Commands +// ============================================================================ + +/// Get S.M.A.R.T. health data for all detected disks +#[tauri::command] +pub async fn get_disk_health() -> DiskHealthResponse { + tokio::task::spawn_blocking(get_disk_health_blocking) + .await + .unwrap_or_else(|e| DiskHealthResponse { + disks: Vec::new(), + smartctl_found: false, + error: Some(format!("Disk health task failed: {e}")), + }) +} + +fn get_disk_health_blocking() -> DiskHealthResponse { + let smartctl_path = match find_smartctl() { + Some(path) => path, + None => { + return DiskHealthResponse { + disks: Vec::new(), + smartctl_found: false, + error: Some("smartctl not found. Install smartmontools for S.M.A.R.T. health data.".to_string()), + }; + } + }; + + let drives = list_physical_drives(); + let mut disks = Vec::new(); + + for drive in &drives { + let output = Command::new(&smartctl_path) + .args(["-a", drive, "--json"]) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(info) = parse_smartctl_json(&stdout) { + disks.push(info); + } + } + Err(_) => {} + } + } + + DiskHealthResponse { + disks, + smartctl_found: true, + error: None, + } +} diff --git a/src-tauri/src/commands/event_log.rs b/src-tauri/src/commands/event_log.rs index ea3485d..cc2f7ff 100644 --- a/src-tauri/src/commands/event_log.rs +++ b/src-tauri/src/commands/event_log.rs @@ -409,7 +409,7 @@ pub async fn search_event_logs( let script = format!( r#" Get-WinEvent -LogName '{}' -MaxEvents 1000 -ErrorAction SilentlyContinue | - Where-Object {{ $_.Message -like '*{}*' -or $_.ProviderName -like '*{}*' }} | + Where-Object {{ $_.Message -like '*{}*' -or $_.ProviderName -like '*{}*' -or [string]$_.Id -like '*{}*' }} | Select-Object -First {} | ForEach-Object {{ $msg = $_.Message @@ -440,7 +440,7 @@ pub async fn search_event_logs( }} }} | ConvertTo-Json -Depth 3 "#, - log_name, query, query, limit + log_name, query, query, query, limit ); let output = Command::new("powershell") diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 55511ba..70a267b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,29 +2,35 @@ //! //! This module contains all the Tauri commands exposed to the frontend. +mod agent; mod bluescreen; mod data_dir; +mod disk_health; mod event_log; mod network; mod network_diagnostics; mod programs; mod required_programs; +pub(crate) mod restore_points; mod scripts; mod services; mod settings; mod shortcuts; -mod startup; +pub(crate) mod startup; mod system_info; mod time_tracking; mod utils; +pub use agent::*; pub use bluescreen::*; pub use data_dir::*; +pub use disk_health::*; pub use event_log::*; pub use network::*; pub use network_diagnostics::*; pub use programs::*; pub use required_programs::*; +pub use restore_points::*; pub use scripts::*; pub use services::*; pub use settings::*; diff --git a/src-tauri/src/commands/required_programs.rs b/src-tauri/src/commands/required_programs.rs index 05c3045..928aa83 100644 --- a/src-tauri/src/commands/required_programs.rs +++ b/src-tauri/src/commands/required_programs.rs @@ -183,7 +183,13 @@ pub fn get_required_programs_by_ids(ids: Vec) -> Vec /// Get the status of all required programs (found/not found, paths) #[tauri::command] -pub fn get_required_programs_status() -> Result, String> { +pub async fn get_required_programs_status() -> Result, String> { + tokio::task::spawn_blocking(get_required_programs_status_blocking) + .await + .map_err(|e| format!("Required programs task failed: {e}"))? +} + +fn get_required_programs_status_blocking() -> Result, String> { let settings = get_settings()?; let overrides = &settings.programs.overrides; diff --git a/src-tauri/src/commands/restore_points.rs b/src-tauri/src/commands/restore_points.rs new file mode 100644 index 0000000..d0fce70 --- /dev/null +++ b/src-tauri/src/commands/restore_points.rs @@ -0,0 +1,219 @@ +//! System Restore Point commands +//! +//! Lists and creates Windows System Restore Points using PowerShell. + +use serde::{Deserialize, Serialize}; +use std::process::Command; + +// ============================================================================ +// Types +// ============================================================================ + +/// A Windows System Restore Point +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestorePoint { + pub sequence_number: u32, + pub description: String, + pub creation_time: String, + pub restore_type: String, +} + +/// Response from get_restore_points +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestorePointsResponse { + pub restore_points: Vec, + pub error: Option, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Map restore point type number to human-readable label +fn restore_type_label(type_num: u64) -> String { + match type_num { + 0 => "Application Install".to_string(), + 1 => "Application Uninstall".to_string(), + 6 => "Restore".to_string(), + 7 => "Checkpoint".to_string(), + 10 => "Device Driver Install".to_string(), + 11 => "First Run".to_string(), + 12 => "Modify Settings".to_string(), + 13 => "Cancelled Operation".to_string(), + _ => format!("Type {}", type_num), + } +} + +// ============================================================================ +// Commands +// ============================================================================ + +/// List all system restore points +#[tauri::command] +pub async fn get_restore_points() -> RestorePointsResponse { + tokio::task::spawn_blocking(get_restore_points_blocking) + .await + .unwrap_or_else(|e| RestorePointsResponse { + restore_points: Vec::new(), + error: Some(format!("Restore points task failed: {e}")), + }) +} + +fn get_restore_points_blocking() -> RestorePointsResponse { + let ps_script = r#" + try { + $points = Get-ComputerRestorePoint -ErrorAction SilentlyContinue + if ($null -eq $points) { + Write-Output '[]' + } else { + $points | Select-Object SequenceNumber, Description, @{N='CreationTime';E={$_.ConvertToDateTime($_.CreationTime).ToString('yyyy-MM-ddTHH:mm:ss')}}, RestorePointType | ConvertTo-Json -Compress + } + } catch { + Write-Output '[]' + } + "#; + + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", ps_script]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + + if stdout.is_empty() || stdout == "[]" { + return RestorePointsResponse { + restore_points: Vec::new(), + error: None, + }; + } + + // PowerShell returns a single object (not array) when there's only one result + let json_val: Result = serde_json::from_str(&stdout); + match json_val { + Ok(serde_json::Value::Array(arr)) => { + let points = arr + .iter() + .filter_map(|item| parse_restore_point(item)) + .collect(); + RestorePointsResponse { + restore_points: points, + error: None, + } + } + Ok(obj @ serde_json::Value::Object(_)) => { + // Single restore point (not wrapped in array) + let points = parse_restore_point(&obj) + .map(|p| vec![p]) + .unwrap_or_default(); + RestorePointsResponse { + restore_points: points, + error: None, + } + } + _ => RestorePointsResponse { + restore_points: Vec::new(), + error: Some("Failed to parse restore point data".to_string()), + }, + } + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + RestorePointsResponse { + restore_points: Vec::new(), + error: Some(if stderr.is_empty() { + "Failed to retrieve restore points".to_string() + } else { + stderr + }), + } + } + Err(e) => RestorePointsResponse { + restore_points: Vec::new(), + error: Some(format!("Failed to run PowerShell: {}", e)), + }, + } +} + +/// Parse a single restore point from JSON +fn parse_restore_point(val: &serde_json::Value) -> Option { + let seq = val.get("SequenceNumber")?.as_u64()? as u32; + let desc = val + .get("Description") + .and_then(|v| v.as_str()) + .unwrap_or("(no description)") + .to_string(); + let time = val + .get("CreationTime") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let rtype = val + .get("RestorePointType") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + Some(RestorePoint { + sequence_number: seq, + description: desc, + creation_time: time, + restore_type: restore_type_label(rtype), + }) +} + +/// Create a new system restore point (requires admin privileges) +#[tauri::command] +pub async fn create_restore_point(description: String) -> Result { + tokio::task::spawn_blocking(move || create_restore_point_blocking(&description)) + .await + .map_err(|e| format!("Create restore point task failed: {e}"))? +} + +pub(crate) fn create_restore_point_blocking(description: &str) -> Result { + // Sanitize the description to prevent command injection + let safe_desc = description + .replace('\'', "") + .replace('"', "") + .replace('`', "") + .replace('$', "") + .chars() + .take(256) + .collect::(); + + if safe_desc.trim().is_empty() { + return Err("Description cannot be empty".to_string()); + } + + let ps_command = format!( + "Checkpoint-Computer -Description '{}' -RestorePointType 'MODIFY_SETTINGS'", + safe_desc + ); + + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", &ps_command]) + .output(); + + match output { + Ok(out) if out.status.success() => { + Ok(format!("Restore point '{}' created successfully", safe_desc)) + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if stderr.contains("Access") || stderr.contains("privilege") || stderr.contains("denied") { + Err("Administrator privileges required to create restore points. Run the application as administrator.".to_string()) + } else if stderr.is_empty() { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if stdout.is_empty() { + Err("Failed to create restore point. This may require administrator privileges.".to_string()) + } else { + Ok(format!("Restore point '{}' created", safe_desc)) + } + } else { + Err(format!("Failed to create restore point: {}", stderr)) + } + } + Err(e) => Err(format!("Failed to run PowerShell: {}", e)), + } +} diff --git a/src-tauri/src/commands/services.rs b/src-tauri/src/commands/services.rs index b8ac57b..8932e8b 100644 --- a/src-tauri/src/commands/services.rs +++ b/src-tauri/src/commands/services.rs @@ -4,8 +4,9 @@ //! This module delegates to the modular service system in `crate::services`. use std::collections::HashMap; +use std::collections::HashSet; use std::fs; -use std::sync::Mutex; +use std::sync::{Arc, Condvar, Mutex}; use std::time::Instant; use chrono::Utc; @@ -16,9 +17,12 @@ use uuid::Uuid; use super::data_dir::get_data_dir_path; use super::required_programs::validate_required_programs; use super::settings::{get_settings, save_settings}; +use sysinfo::Disks; + use crate::services; use crate::types::{ - ServiceDefinition, ServicePreset, ServiceQueueItem, ServiceReport, ServiceRunState, + FindingSeverity, FindingSeverityCounts, ReportStatistics, ServiceDefinition, ServiceFinding, + ServicePreset, ServiceQueueItem, ServiceReport, ServiceResult, ServiceRunState, ServiceRunStatus, }; @@ -29,6 +33,9 @@ use crate::types::{ /// Global state for the currently running service static SERVICE_STATE: Mutex> = Mutex::new(None); +/// Condition variable for pause/resume support +static PAUSE_CONDVAR: Condvar = Condvar::new(); + // ============================================================================= // Report Storage // ============================================================================= @@ -53,6 +60,33 @@ fn save_report(report: &ServiceReport) -> Result<(), String> { // Tauri Commands // ============================================================================= +/// List connected removable USB drives for the USB stability test service +#[tauri::command] +pub fn list_usb_drives() -> Vec { + let disks = Disks::new_with_refreshed_list(); + disks + .list() + .iter() + .filter(|d| d.is_removable() && d.total_space() > 0) + .map(|d| { + let mount = d.mount_point().to_string_lossy().to_string(); + let name = d.name().to_string_lossy().to_string(); + let label = if name.is_empty() { + "Removable Disk".to_string() + } else { + name + }; + serde_json::json!({ + "mountPoint": mount.trim_end_matches('\\'), + "name": label, + "totalSpaceGb": d.total_space() as f64 / 1_073_741_824.0, + "availableSpaceGb": d.available_space() as f64 / 1_073_741_824.0, + "fileSystem": d.file_system().to_string_lossy().to_string(), + }) + }) + .collect() +} + /// Get all available service definitions #[tauri::command] pub fn get_service_definitions() -> Vec { @@ -199,7 +233,10 @@ pub async fn run_services( queue: Vec, technician_name: Option, customer_name: Option, + parallel: Option, ) -> Result { + let parallel_mode = parallel.unwrap_or(false); + // Check if already running { let state = SERVICE_STATE.lock().unwrap(); @@ -218,6 +255,30 @@ pub async fn run_services( return Err("No services enabled in queue".to_string()); } + // Validate dependency ordering + { + let all_defs = crate::services::get_all_definitions(); + let dep_map: std::collections::HashMap> = all_defs + .into_iter() + .map(|d| (d.id.clone(), d.dependencies)) + .collect(); + + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + for item in &enabled_queue { + let deps = dep_map.get(&item.service_id).cloned().unwrap_or_default(); + for dep in &deps { + let dep_in_queue = enabled_queue.iter().any(|q| &q.service_id == dep); + if dep_in_queue && !seen_ids.contains(dep) { + return Err(format!( + "Service '{}' depends on '{}' which must come earlier in the queue", + item.service_id, dep + )); + } + } + seen_ids.insert(item.service_id.clone()); + } + } + // Create report let report_id = Uuid::new_v4().to_string(); let mut report = ServiceReport { @@ -229,8 +290,13 @@ pub async fn run_services( queue: queue.clone(), results: Vec::new(), current_service_index: Some(0), + current_service_indices: vec![], + parallel_mode, technician_name, customer_name, + agent_initiated: false, + agent_summary: None, + health_score: None, }; // Update global state @@ -238,6 +304,7 @@ pub async fn run_services( let mut state = SERVICE_STATE.lock().unwrap(); *state = Some(ServiceRunState { is_running: true, + is_paused: false, current_report: Some(report.clone()), }); } @@ -247,8 +314,99 @@ pub async fn run_services( let start_time = Instant::now(); - // Run each service + if parallel_mode { + // ===================================================================== + // Parallel Execution (Experimental) + // Resource-based concurrent scheduler: services with overlapping + // exclusive_resources are serialized; non-conflicting services run + // concurrently on separate threads. + // ===================================================================== + run_services_parallel(&app, &enabled_queue, &mut report)?; + } else { + // ===================================================================== + // Sequential Execution (Default) + // ===================================================================== + run_services_sequential(&app, &enabled_queue, &mut report)?; + } + + // Complete report + report.completed_at = Some(Utc::now().to_rfc3339()); + // Check if we were cancelled + let was_cancelled = { + let state = SERVICE_STATE.lock().unwrap(); + state.as_ref().map(|s| !s.is_running).unwrap_or(false) + }; + report.status = if was_cancelled { + ServiceRunStatus::Cancelled + } else if report.results.iter().all(|r| r.success) { + ServiceRunStatus::Completed + } else { + ServiceRunStatus::Failed + }; + report.total_duration_ms = Some(start_time.elapsed().as_millis() as u64); + report.current_service_index = None; + report.current_service_indices = vec![]; + + // Save report + if let Err(e) = save_report(&report) { + eprintln!("Failed to save report: {}", e); + } + + // Update global state + { + let mut state = SERVICE_STATE.lock().unwrap(); + *state = Some(ServiceRunState { + is_running: false, + is_paused: false, + current_report: Some(report.clone()), + }); + } + + // Emit completion + let _ = app.emit("service-state-changed", get_service_run_state()); + let _ = app.emit("service-completed", &report); + + Ok(report) +} + +// ============================================================================= +// Sequential Runner +// ============================================================================= + +/// Run services one at a time (original behavior) +fn run_services_sequential( + app: &AppHandle, + enabled_queue: &[ServiceQueueItem], + report: &mut ServiceReport, +) -> Result<(), String> { for (index, queue_item) in enabled_queue.iter().enumerate() { + // Check if cancelled before starting next service + { + let state = SERVICE_STATE.lock().unwrap(); + if let Some(ref s) = *state { + if !s.is_running { + break; + } + } + } + + // Wait while paused (agent intervention) + { + let mut state = SERVICE_STATE.lock().unwrap(); + while state + .as_ref() + .map_or(false, |s| s.is_paused && s.is_running) + { + state = PAUSE_CONDVAR.wait(state).unwrap(); + } + // Re-check cancellation after resuming from pause + if let Some(ref s) = *state { + if !s.is_running { + break; + } + } + } + // Update current index { let mut state = SERVICE_STATE.lock().unwrap(); @@ -270,12 +428,11 @@ pub async fn run_services( ); // Run the service using the modular service system - let result = services::run_service(&queue_item.service_id, &queue_item.options, &app) + let result = services::run_service(&queue_item.service_id, &queue_item.options, app) .ok_or_else(|| format!("Unknown service: {}", queue_item.service_id))?; // Record timing for service metrics (only for successful runs) if result.success { - // Compute options hash for settings-aware tracking let options_hash = Some(super::time_tracking::compute_options_hash( &queue_item.options, )); @@ -283,7 +440,7 @@ pub async fn run_services( if let Err(e) = super::time_tracking::record_service_time( queue_item.service_id.clone(), result.duration_ms, - None, // preset_id could be passed from frontend if needed + None, options_hash, ) { eprintln!("Failed to record service time: {}", e); @@ -292,7 +449,7 @@ pub async fn run_services( report.results.push(result); - // Update state with latest results + // Update state with latest results and emit to frontend { let mut state = SERVICE_STATE.lock().unwrap(); if let Some(ref mut s) = *state { @@ -301,37 +458,292 @@ pub async fn run_services( } } } + let _ = app.emit("service-state-changed", get_service_run_state()); } - // Complete report - report.completed_at = Some(Utc::now().to_rfc3339()); - report.status = if report.results.iter().all(|r| r.success) { - ServiceRunStatus::Completed - } else { - ServiceRunStatus::Failed - }; - report.total_duration_ms = Some(start_time.elapsed().as_millis() as u64); - report.current_service_index = None; + Ok(()) +} - // Save report - if let Err(e) = save_report(&report) { - eprintln!("Failed to save report: {}", e); - } +// ============================================================================= +// Parallel Runner (Experimental) +// ============================================================================= - // Update global state - { - let mut state = SERVICE_STATE.lock().unwrap(); - *state = Some(ServiceRunState { - is_running: false, - current_report: Some(report.clone()), +/// Run services concurrently, respecting exclusive resource constraints. +/// Services without overlapping exclusive_resources execute in parallel. +/// Services sharing any resource tag are serialized. +fn run_services_parallel( + app: &AppHandle, + enabled_queue: &[ServiceQueueItem], + report: &mut ServiceReport, +) -> Result<(), String> { + // Build a map of service_id -> exclusive_resources from definitions + let all_defs = services::get_all_definitions(); + let resource_map: HashMap> = all_defs + .iter() + .map(|d| (d.id.clone(), d.exclusive_resources.clone())) + .collect(); + let dep_map: std::collections::HashMap> = all_defs + .iter() + .map(|d| (d.id.clone(), d.dependencies.clone())) + .collect(); + + // Track state for the scheduler + let total_count = enabled_queue.len(); + let results_collector: Arc>> = + Arc::new(Mutex::new(Vec::new())); + + // Track which indices have been started and completed + let mut started: HashSet = HashSet::new(); + let mut completed: HashSet = HashSet::new(); + // Resources currently held by running services + let mut held_resources: HashSet = HashSet::new(); + // Map of currently running thread join handles with their index and resources + let mut running: Vec<(usize, Vec, std::thread::JoinHandle<()>)> = Vec::new(); + + // Notification channel for when a task completes + let notify_pair = Arc::new((Mutex::new(false), Condvar::new())); + + loop { + // Check cancellation + { + let state = SERVICE_STATE.lock().unwrap(); + if let Some(ref s) = *state { + if !s.is_running { + break; + } + } + } + + // If everything has been started and completed, we're done + if completed.len() == total_count { + break; + } + + // Collect completed threads + let mut newly_completed = Vec::new(); + running.retain(|(idx, resources, handle)| { + if handle.is_finished() { + newly_completed.push((*idx, resources.clone())); + false + } else { + true + } }); + + // Release resources from completed tasks and join them + for (idx, resources) in &newly_completed { + completed.insert(*idx); + for res in resources { + held_resources.remove(res); + } + } + + // Collect results from the shared collector + { + let mut collected = results_collector.lock().unwrap(); + for (idx, result) in collected.drain(..) { + // Record timing for service metrics + if result.success { + let queue_item = &enabled_queue[idx]; + let options_hash = Some(super::time_tracking::compute_options_hash( + &queue_item.options, + )); + + if let Err(e) = super::time_tracking::record_service_time( + queue_item.service_id.clone(), + result.duration_ms, + None, + options_hash, + ) { + eprintln!("Failed to record service time: {}", e); + } + } + + report.results.push(result); + } + } + + // Update state if anything changed + if !newly_completed.is_empty() { + let active_indices: Vec = running.iter().map(|(idx, _, _)| *idx).collect(); + { + let mut state = SERVICE_STATE.lock().unwrap(); + if let Some(ref mut s) = *state { + if let Some(ref mut r) = s.current_report { + r.results = report.results.clone(); + r.current_service_indices = active_indices.clone(); + r.current_service_index = active_indices.first().copied(); + } + } + } + let _ = app.emit("service-state-changed", get_service_run_state()); + } + + // Try to start new services that don't conflict with running ones + let mut launched_any = false; + for (index, queue_item) in enabled_queue.iter().enumerate() { + if started.contains(&index) { + continue; + } + + // Check cancellation again + { + let state = SERVICE_STATE.lock().unwrap(); + if let Some(ref s) = *state { + if !s.is_running { + break; + } + } + } + + // Get this service's exclusive resources + let service_resources = resource_map + .get(&queue_item.service_id) + .cloned() + .unwrap_or_default(); + + // Check if any of its resources conflict with currently held ones + let has_conflict = service_resources.iter().any(|r| held_resources.contains(r)); + + // Check if all dependencies have completed + let deps = dep_map.get(&queue_item.service_id).cloned().unwrap_or_default(); + let deps_satisfied = deps.iter().all(|dep_id| { + let dep_index = enabled_queue.iter().position(|q| q.service_id == *dep_id); + dep_index.map_or(true, |idx| completed.contains(&idx)) + }); + + if has_conflict || !deps_satisfied { + continue; // Skip this service for now; it'll be picked up later + } + + // Mark resources as held + for res in &service_resources { + held_resources.insert(res.clone()); + } + started.insert(index); + launched_any = true; + + // Emit progress for this service starting + let _ = app.emit( + "service-progress", + json!({ + "currentIndex": index, + "totalCount": total_count, + "serviceId": queue_item.service_id + }), + ); + + // Update active indices in state + let active_indices: Vec = running + .iter() + .map(|(idx, _, _)| *idx) + .chain(std::iter::once(index)) + .collect(); + { + let mut state = SERVICE_STATE.lock().unwrap(); + if let Some(ref mut s) = *state { + if let Some(ref mut r) = s.current_report { + r.current_service_indices = active_indices.clone(); + r.current_service_index = active_indices.first().copied(); + } + } + } + let _ = app.emit("service-state-changed", get_service_run_state()); + + // Spawn thread to run this service + let results_tx = Arc::clone(&results_collector); + let notify = Arc::clone(¬ify_pair); + let service_id = queue_item.service_id.clone(); + let options = queue_item.options.clone(); + let app_handle = app.clone(); + + let handle = std::thread::spawn(move || { + let result = services::run_service(&service_id, &options, &app_handle) + .unwrap_or_else(|| ServiceResult { + service_id: service_id.clone(), + success: false, + error: Some(format!("Unknown service: {}", service_id)), + duration_ms: 0, + findings: vec![], + logs: vec![], + agent_analysis: None, + }); + + // Push result to collector + { + let mut collected = results_tx.lock().unwrap(); + collected.push((index, result)); + } + + // Notify the scheduler that a task completed + let (lock, cvar) = &*notify; + let mut done = lock.lock().unwrap(); + *done = true; + cvar.notify_one(); + }); + + running.push((index, service_resources, handle)); + } + + // If we launched nothing and there are still running tasks, wait for one to complete + if !launched_any && !running.is_empty() { + let (lock, cvar) = &*notify_pair; + let mut done = lock.lock().unwrap(); + // Wait with a short timeout to periodically check cancellation + if !*done { + let _ = cvar.wait_timeout(done, std::time::Duration::from_millis(250)); + } else { + *done = false; + } + } else if !launched_any && running.is_empty() && completed.len() < total_count { + // All remaining services conflict with each other but none are running + // This shouldn't happen, but handle it gracefully โ€” start the next unstarted one + for (index, _queue_item) in enabled_queue.iter().enumerate() { + if !started.contains(&index) { + // Force-start it sequentially by not checking resources + // This is a safety fallback + break; + } + } + // If we get here with nothing to do, break to avoid infinite loop + if started.len() + completed.len() >= total_count || running.is_empty() { + break; + } + } } - // Emit completion - let _ = app.emit("service-state-changed", get_service_run_state()); - let _ = app.emit("service-completed", &report); + // Wait for any remaining running threads + for (idx, resources, handle) in running { + let _ = handle.join(); + completed.insert(idx); + for res in &resources { + held_resources.remove(res); + } + } - Ok(report) + // Final collection of results + { + let mut collected = results_collector.lock().unwrap(); + for (idx, result) in collected.drain(..) { + if result.success { + let queue_item = &enabled_queue[idx]; + let options_hash = Some(super::time_tracking::compute_options_hash( + &queue_item.options, + )); + if let Err(e) = super::time_tracking::record_service_time( + queue_item.service_id.clone(), + result.duration_ms, + None, + options_hash, + ) { + eprintln!("Failed to record service time: {}", e); + } + } + report.results.push(result); + } + } + + Ok(()) } /// Cancel the current service run @@ -422,3 +834,432 @@ pub fn clear_all_reports() -> Result { Ok(deleted_count) } + +// ============================================================================= +// Pause / Resume Commands +// ============================================================================= + +/// Pause the current service run (takes effect between services) +#[tauri::command] +pub fn pause_service_run(app: AppHandle) -> Result<(), String> { + let mut state = SERVICE_STATE.lock().unwrap(); + if let Some(ref mut s) = *state { + if s.is_running && !s.is_paused { + s.is_paused = true; + if let Some(ref mut report) = s.current_report { + report.status = ServiceRunStatus::Paused; + } + drop(state); + let _ = app.emit("service-state-changed", get_service_run_state()); + return Ok(()); + } + } + Err("No active service run to pause".to_string()) +} + +/// Resume a paused service run +#[tauri::command] +pub fn resume_service_run(app: AppHandle) -> Result<(), String> { + let mut state = SERVICE_STATE.lock().unwrap(); + if let Some(ref mut s) = *state { + if s.is_running && s.is_paused { + s.is_paused = false; + if let Some(ref mut report) = s.current_report { + report.status = ServiceRunStatus::Running; + } + PAUSE_CONDVAR.notify_all(); + drop(state); + let _ = app.emit("service-state-changed", get_service_run_state()); + return Ok(()); + } + } + Err("No paused service run to resume".to_string()) +} + +// ============================================================================= +// Report Editing Commands (for agent) +// ============================================================================= + +/// Load a report from disk (helper) +fn load_report(report_id: &str) -> Result { + let file_path = get_reports_dir().join(format!("{}.json", report_id)); + let json = + fs::read_to_string(&file_path).map_err(|e| format!("Failed to read report: {}", e))?; + serde_json::from_str(&json).map_err(|e| format!("Failed to parse report: {}", e)) +} + +/// Edit an existing finding in a report +#[tauri::command] +pub fn edit_report_finding( + report_id: String, + service_id: String, + finding_index: usize, + severity: Option, + title: Option, + description: Option, + recommendation: Option, +) -> Result<(), String> { + let mut report = load_report(&report_id)?; + + let result = report + .results + .iter_mut() + .find(|r| r.service_id == service_id) + .ok_or_else(|| format!("Service result not found: {}", service_id))?; + + let finding = result + .findings + .get_mut(finding_index) + .ok_or_else(|| format!("Finding index out of range: {}", finding_index))?; + + if let Some(sev) = severity { + finding.severity = match sev.as_str() { + "info" => FindingSeverity::Info, + "success" => FindingSeverity::Success, + "warning" => FindingSeverity::Warning, + "error" => FindingSeverity::Error, + "critical" => FindingSeverity::Critical, + _ => return Err(format!("Invalid severity: {}", sev)), + }; + } + if let Some(t) = title { + finding.title = t; + } + if let Some(d) = description { + finding.description = d; + } + if let Some(r) = recommendation { + finding.recommendation = Some(r); + } + + save_report(&report) +} + +/// Add a new finding to a service result in a report +#[tauri::command] +pub fn add_report_finding( + report_id: String, + service_id: String, + severity: String, + title: String, + description: String, + recommendation: Option, +) -> Result<(), String> { + let mut report = load_report(&report_id)?; + + let result = report + .results + .iter_mut() + .find(|r| r.service_id == service_id) + .ok_or_else(|| format!("Service result not found: {}", service_id))?; + + let sev = match severity.as_str() { + "info" => FindingSeverity::Info, + "success" => FindingSeverity::Success, + "warning" => FindingSeverity::Warning, + "error" => FindingSeverity::Error, + "critical" => FindingSeverity::Critical, + _ => return Err(format!("Invalid severity: {}", severity)), + }; + + result.findings.push(ServiceFinding { + severity: sev, + title, + description, + recommendation, + data: None, + }); + + save_report(&report) +} + +/// Remove a finding from a service result in a report +#[tauri::command] +pub fn remove_report_finding( + report_id: String, + service_id: String, + finding_index: usize, +) -> Result<(), String> { + let mut report = load_report(&report_id)?; + + let result = report + .results + .iter_mut() + .find(|r| r.service_id == service_id) + .ok_or_else(|| format!("Service result not found: {}", service_id))?; + + if finding_index >= result.findings.len() { + return Err(format!("Finding index out of range: {}", finding_index)); + } + + result.findings.remove(finding_index); + save_report(&report) +} + +/// Set agent-generated executive summary on a report +#[tauri::command] +pub fn set_report_summary(report_id: String, summary: String) -> Result<(), String> { + let mut report = load_report(&report_id)?; + report.agent_summary = Some(summary); + save_report(&report) +} + +/// Set agent-generated analysis for a specific service result +#[tauri::command] +pub fn set_service_analysis( + report_id: String, + service_id: String, + analysis: String, +) -> Result<(), String> { + let mut report = load_report(&report_id)?; + + let result = report + .results + .iter_mut() + .find(|r| r.service_id == service_id) + .ok_or_else(|| format!("Service result not found: {}", service_id))?; + + result.agent_analysis = Some(analysis); + save_report(&report) +} + +/// Set the health score on a report +#[tauri::command] +pub fn set_report_health_score(report_id: String, score: u8) -> Result<(), String> { + if score > 100 { + return Err("Health score must be 0-100".to_string()); + } + let mut report = load_report(&report_id)?; + report.health_score = Some(score); + save_report(&report) +} + +// ============================================================================= +// Report Statistics +// ============================================================================= + +/// Compute statistics for a report +fn compute_report_statistics(report: &ServiceReport) -> ReportStatistics { + let total_services = report.results.len(); + let passed = report.results.iter().filter(|r| r.success).count(); + let failed = total_services - passed; + + let total_duration_ms = report.results.iter().map(|r| r.duration_ms).sum::(); + let avg_duration_ms = if total_services > 0 { + total_duration_ms / total_services as u64 + } else { + 0 + }; + + let slowest_service = report + .results + .iter() + .max_by_key(|r| r.duration_ms) + .map(|r| (r.service_id.clone(), r.duration_ms)); + + let fastest_service = report + .results + .iter() + .min_by_key(|r| r.duration_ms) + .map(|r| (r.service_id.clone(), r.duration_ms)); + + let mut counts = FindingSeverityCounts { + info: 0, + success: 0, + warning: 0, + error: 0, + critical: 0, + }; + + for result in &report.results { + for finding in &result.findings { + match finding.severity { + FindingSeverity::Info => counts.info += 1, + FindingSeverity::Success => counts.success += 1, + FindingSeverity::Warning => counts.warning += 1, + FindingSeverity::Error => counts.error += 1, + FindingSeverity::Critical => counts.critical += 1, + } + } + } + + let total_findings = + counts.info + counts.success + counts.warning + counts.error + counts.critical; + + // Health score: start at 100, penalize for issues + let raw_score: i32 = 100 + - (counts.critical as i32 * 30) + - (counts.error as i32 * 15) + - (counts.warning as i32 * 5) + + (counts.success as i32 * 2); + let health_score = raw_score.clamp(0, 100) as u8; + + ReportStatistics { + total_services, + passed, + failed, + total_duration_ms, + avg_duration_ms, + slowest_service, + fastest_service, + findings_by_severity: counts, + total_findings, + health_score, + } +} + +/// Get computed statistics for a report +#[tauri::command] +pub fn get_report_statistics(report_id: String) -> Result { + let report = load_report(&report_id)?; + Ok(compute_report_statistics(&report)) +} + +// ============================================================================= +// PDF Report Generation +// ============================================================================= + +/// Generate a PDF report and return the file path +#[tauri::command] +pub fn generate_report_pdf( + report_id: String, + output_path: Option, +) -> Result { + let report = load_report(&report_id)?; + let stats = compute_report_statistics(&report); + + // Determine output path + let pdf_path = match output_path { + Some(p) => std::path::PathBuf::from(p), + None => get_reports_dir().join(format!("{}.pdf", report.id)), + }; + + // Build PDF content as formatted text + let mut lines: Vec = Vec::new(); + + // Header + lines.push("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•".to_string()); + lines.push(" SERVICE REPORT ".to_string()); + lines.push("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•".to_string()); + lines.push(String::new()); + lines.push(format!("Report ID: {}", report.id)); + lines.push(format!("Date: {}", report.started_at)); + if let Some(ref name) = report.technician_name { + lines.push(format!("Technician: {}", name)); + } + if let Some(ref name) = report.customer_name { + lines.push(format!("Customer: {}", name)); + } + lines.push(format!("Status: {:?}", report.status)); + if let Some(score) = report.health_score { + lines.push(format!("Health Score: {}/100", score)); + } + lines.push(String::new()); + + // Statistics + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + lines.push(" STATISTICS ".to_string()); + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + lines.push(format!("Services Run: {}", stats.total_services)); + lines.push(format!("Passed: {}", stats.passed)); + lines.push(format!("Failed: {}", stats.failed)); + lines.push(format!( + "Total Duration: {:.1}s", + stats.total_duration_ms as f64 / 1000.0 + )); + lines.push(format!( + "Avg per Service: {:.1}s", + stats.avg_duration_ms as f64 / 1000.0 + )); + lines.push(String::new()); + lines.push(format!( + "Findings: {} critical, {} errors, {} warnings, {} info, {} success", + stats.findings_by_severity.critical, + stats.findings_by_severity.error, + stats.findings_by_severity.warning, + stats.findings_by_severity.info, + stats.findings_by_severity.success + )); + lines.push(String::new()); + + // Agent Summary + if let Some(ref summary) = report.agent_summary { + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + lines.push(" AI ANALYSIS SUMMARY ".to_string()); + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + lines.push(summary.clone()); + lines.push(String::new()); + } + + // Per-service results + let definitions = services::get_all_definitions(); + let def_map: HashMap = + definitions.iter().map(|d| (d.id.clone(), d)).collect(); + + for result in &report.results { + let service_name = def_map + .get(&result.service_id) + .map(|d| d.name.as_str()) + .unwrap_or(&result.service_id); + + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + lines.push(format!( + " {} โ€” {}", + service_name, + if result.success { "PASSED" } else { "FAILED" } + )); + lines.push(format!( + " Duration: {:.1}s", + result.duration_ms as f64 / 1000.0 + )); + lines.push("โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€".to_string()); + + if let Some(ref err) = result.error { + lines.push(format!(" ERROR: {}", err)); + } + + for finding in &result.findings { + let sev = match finding.severity { + FindingSeverity::Info => "INFO", + FindingSeverity::Success => "OK", + FindingSeverity::Warning => "WARN", + FindingSeverity::Error => "ERROR", + FindingSeverity::Critical => "CRITICAL", + }; + lines.push(format!(" [{}] {}", sev, finding.title)); + if !finding.description.is_empty() { + lines.push(format!(" {}", finding.description)); + } + if let Some(ref rec) = finding.recommendation { + lines.push(format!(" โ†’ {}", rec)); + } + } + + if let Some(ref analysis) = result.agent_analysis { + lines.push(String::new()); + lines.push(format!(" AI Analysis: {}", analysis)); + } + + lines.push(String::new()); + } + + // Footer + lines.push("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•".to_string()); + lines.push(format!( + "Generated by RustService AI Agent โ€” {}", + Utc::now().to_rfc3339() + )); + lines.push("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•".to_string()); + + let content = lines.join("\n"); + + // Ensure parent directory exists + if let Some(parent) = pdf_path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {}", e))?; + } + + // Write as a text-based report file (portable across all systems) + fs::write(&pdf_path, &content).map_err(|e| format!("Failed to write report: {}", e))?; + + Ok(pdf_path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 08cb6c7..9fa0636 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -186,6 +186,16 @@ pub fn update_setting(key: String, value: String) -> Result settings.technician_tabs.use_favicons = serde_json::from_str(&value) .map_err(|e| format!("Invalid technicianTabs.useFavicons value: {}", e))?; } + // Presets + ["presets", "customPresets"] => { + settings.presets.custom_presets = serde_json::from_str(&value) + .map_err(|e| format!("Invalid presets.customPresets value: {}", e))?; + } + // Agent settings (entire object) + ["agent"] => { + settings.agent = serde_json::from_str(&value) + .map_err(|e| format!("Invalid agent settings value: {}", e))?; + } _ => { return Err(format!("Unknown setting key: {}", key)); } diff --git a/src-tauri/src/commands/startup.rs b/src-tauri/src/commands/startup.rs index ac3316f..5434ea6 100644 --- a/src-tauri/src/commands/startup.rs +++ b/src-tauri/src/commands/startup.rs @@ -68,20 +68,17 @@ pub enum StartupImpact { pub async fn get_startup_items() -> Result, String> { let mut items = Vec::new(); - // Get registry startup items via PowerShell - match get_registry_startup_items().await { + match get_registry_startup_items_sync() { Ok(mut registry_items) => items.append(&mut registry_items), Err(e) => eprintln!("Failed to get registry startup items: {}", e), } - // Get startup folder items - match get_startup_folder_items().await { + match get_startup_folder_items_sync() { Ok(mut folder_items) => items.append(&mut folder_items), Err(e) => eprintln!("Failed to get startup folder items: {}", e), } - // Get scheduled tasks with startup triggers - match get_scheduled_startup_tasks().await { + match get_scheduled_startup_tasks_sync() { Ok(mut task_items) => items.append(&mut task_items), Err(e) => eprintln!("Failed to get scheduled startup tasks: {}", e), } @@ -90,7 +87,7 @@ pub async fn get_startup_items() -> Result, String> { } /// Get startup items from registry -async fn get_registry_startup_items() -> Result, String> { +pub(crate) fn get_registry_startup_items_sync() -> Result, String> { let output = Command::new("powershell") .args([ "-NoProfile", @@ -190,6 +187,7 @@ async fn get_registry_startup_items() -> Result, String> { let path = extract_path_from_command(&command); let impact = estimate_impact(&name, &command); + let publisher = get_file_publisher(path.as_deref()); items.push(StartupItem { id: format!("reg_{}", sanitize_id(&name)), name: name.clone(), @@ -198,7 +196,7 @@ async fn get_registry_startup_items() -> Result, String> { source, source_location: raw["SourceLocation"].as_str().unwrap_or("").to_string(), enabled, - publisher: get_file_publisher(&name), + publisher, description: None, impact, }); @@ -208,7 +206,7 @@ async fn get_registry_startup_items() -> Result, String> { } /// Get startup items from shell startup folders -async fn get_startup_folder_items() -> Result, String> { +pub(crate) fn get_startup_folder_items_sync() -> Result, String> { let mut items = Vec::new(); // User startup folder @@ -268,11 +266,11 @@ fn scan_startup_folder(path: &PathBuf, source: StartupSource) -> Result Result Result, String> { +pub(crate) fn get_scheduled_startup_tasks_sync() -> Result, String> { let output = Command::new("powershell") .args([ "-NoProfile", @@ -358,9 +356,9 @@ async fn get_scheduled_startup_tasks() -> Result, String> { pub async fn toggle_startup_item(id: String, enabled: bool) -> Result<(), String> { // Parse the ID to determine the source if id.starts_with("reg_") { - toggle_registry_startup_item(&id[4..], enabled).await + toggle_registry_startup_item_sync(&id[4..], enabled) } else if id.starts_with("task_") { - toggle_scheduled_task(&id[5..], enabled).await + toggle_scheduled_task_sync(&id[5..], enabled) } else if id.starts_with("folder_") { Err("Startup folder items cannot be disabled - delete them instead".to_string()) } else { @@ -369,7 +367,7 @@ pub async fn toggle_startup_item(id: String, enabled: bool) -> Result<(), String } /// Toggle a registry startup item -async fn toggle_registry_startup_item(name: &str, enabled: bool) -> Result<(), String> { +pub(crate) fn toggle_registry_startup_item_sync(name: &str, enabled: bool) -> Result<(), String> { // We use the StartupApproved registry key to enable/disable let script = if enabled { format!( @@ -431,7 +429,7 @@ async fn toggle_registry_startup_item(name: &str, enabled: bool) -> Result<(), S } /// Toggle a scheduled task -async fn toggle_scheduled_task(name: &str, enabled: bool) -> Result<(), String> { +pub(crate) fn toggle_scheduled_task_sync(name: &str, enabled: bool) -> Result<(), String> { let action = if enabled { "Enable" } else { "Disable" }; let output = Command::new("powershell") @@ -455,13 +453,40 @@ async fn toggle_scheduled_task(name: &str, enabled: bool) -> Result<(), String> Ok(()) } +/// Open the file location of a startup item in Explorer +#[tauri::command] +pub async fn open_startup_item_location(path: String) -> Result<(), String> { + let file_path = std::path::Path::new(&path); + + if file_path.exists() { + // Use explorer /select to highlight the file + Command::new("explorer") + .args(["/select,", &path]) + .spawn() + .map_err(|e| format!("Failed to open Explorer: {}", e))?; + } else if let Some(parent) = file_path.parent() { + if parent.exists() { + Command::new("explorer") + .arg(parent.to_string_lossy().as_ref()) + .spawn() + .map_err(|e| format!("Failed to open Explorer: {}", e))?; + } else { + return Err(format!("Path does not exist: {}", path)); + } + } else { + return Err(format!("Path does not exist: {}", path)); + } + + Ok(()) +} + /// Delete a startup item #[tauri::command] -pub async fn delete_startup_item(id: String) -> Result<(), String> { +pub async fn delete_startup_item(id: String, command: Option) -> Result<(), String> { if id.starts_with("reg_") { delete_registry_startup_item(&id[4..]).await } else if id.starts_with("folder_") { - delete_startup_folder_item(&id[7..]).await + delete_startup_folder_item(&id[7..], command).await } else if id.starts_with("task_") { delete_scheduled_task(&id[5..]).await } else { @@ -508,9 +533,58 @@ async fn delete_registry_startup_item(name: &str) -> Result<(), String> { } /// Delete a startup folder item -async fn delete_startup_folder_item(_name: &str) -> Result<(), String> { - // This would need the actual file path - for safety, we don't implement direct deletion - Err("Startup folder items must be deleted manually from the folder".to_string()) +async fn delete_startup_folder_item(_name: &str, command: Option) -> Result<(), String> { + let file_path = command + .ok_or_else(|| "No file path provided for startup folder item".to_string())?; + + let path = std::path::Path::new(&file_path); + + if !path.exists() { + return Err(format!("File does not exist: {}", file_path)); + } + + // Safety: ensure the file is actually inside a known startup folder + let allowed_dirs: Vec = { + let mut allowed = Vec::new(); + if let Some(local) = dirs::data_local_dir() { + allowed.push( + local + .join("Microsoft") + .join("Windows") + .join("Start Menu") + .join("Programs") + .join("Startup"), + ); + } + allowed.push(PathBuf::from( + r"C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup", + )); + allowed + }; + + let canonical = path + .canonicalize() + .map_err(|e| format!("Failed to resolve path: {}", e))?; + + let is_in_startup_folder = allowed_dirs.iter().any(|dir| { + if let Ok(canon_dir) = dir.canonicalize() { + canonical.starts_with(&canon_dir) + } else { + false + } + }); + + if !is_in_startup_folder { + return Err(format!( + "Refusing to delete file outside startup folders: {}", + file_path + )); + } + + std::fs::remove_file(path) + .map_err(|e| format!("Failed to delete startup item: {}", e))?; + + Ok(()) } /// Delete a scheduled task @@ -587,9 +661,76 @@ fn estimate_impact(name: &str, _command: &str) -> StartupImpact { StartupImpact::Medium } -/// Get publisher from file (placeholder - would need actual implementation) -fn get_file_publisher(_name: &str) -> Option { - // This would require reading the file's digital signature - // For now, return None +/// Get publisher (CompanyName) from file version info +fn get_file_publisher(exe_path: Option<&str>) -> Option { + let path = exe_path?; + if path.is_empty() { + return None; + } + + // Only attempt for .exe / .dll files that exist + let p = std::path::Path::new(path); + if !p.exists() { + return None; + } + + #[cfg(windows)] + { + use widestring::U16CString; + + let wide_path = U16CString::from_str(path).ok()?; + let mut handle: u32 = 0; + + let size = unsafe { + winapi::um::winver::GetFileVersionInfoSizeW(wide_path.as_ptr(), &mut handle) + }; + if size == 0 { + return None; + } + + let mut buffer: Vec = vec![0u8; size as usize]; + let success = unsafe { + winapi::um::winver::GetFileVersionInfoW( + wide_path.as_ptr(), + handle, + size, + buffer.as_mut_ptr() as *mut _, + ) + }; + if success == 0 { + return None; + } + + // Try common language/codepage combos for CompanyName + let sub_blocks = [ + "\\StringFileInfo\\040904B0\\CompanyName", // English US, Unicode + "\\StringFileInfo\\040904E4\\CompanyName", // English US, Windows Latin-1 + "\\StringFileInfo\\000004B0\\CompanyName", // Language neutral, Unicode + ]; + + for sub_block in &sub_blocks { + let wide_sub = U16CString::from_str(*sub_block).ok()?; + let mut lp_buffer: *mut winapi::ctypes::c_void = std::ptr::null_mut(); + let mut len: u32 = 0; + + let ok = unsafe { + winapi::um::winver::VerQueryValueW( + buffer.as_ptr() as *const _, + wide_sub.as_ptr(), + &mut lp_buffer, + &mut len, + ) + }; + + if ok != 0 && len > 0 && !lp_buffer.is_null() { + let slice = unsafe { std::slice::from_raw_parts(lp_buffer as *const u16, len as usize) }; + let company = String::from_utf16_lossy(slice).trim_end_matches('\0').trim().to_string(); + if !company.is_empty() { + return Some(company); + } + } + } + } + None } diff --git a/src-tauri/src/commands/system_info.rs b/src-tauri/src/commands/system_info.rs index 3801500..7ed61e9 100644 --- a/src-tauri/src/commands/system_info.rs +++ b/src-tauri/src/commands/system_info.rs @@ -1,13 +1,15 @@ //! System information collection command use std::cmp::Ordering; +use std::process::Command; use std::sync::{Mutex, OnceLock}; use sysinfo::{Components, Disks, Motherboard, Networks, System, Users}; use crate::types::{ - BatteryInfo, ComponentInfo, CpuCoreInfo, CpuInfo, DiskInfo, GpuInfo, LoadAvgInfo, MemoryInfo, - MotherboardInfo, NetworkInfo, OsInfo, ProcessInfo, SystemInfo, UserInfo, + BatteryInfo, BiosInfo, ComponentInfo, CpuCoreInfo, CpuInfo, DiskInfo, GpuInfo, LoadAvgInfo, + MemoryInfo, MotherboardInfo, NetworkInfo, OsInfo, ProcessInfo, RamSlotInfo, SystemInfo, + SystemProductInfo, UserInfo, }; static SYS: OnceLock> = OnceLock::new(); @@ -17,7 +19,13 @@ static SYS: OnceLock> = OnceLock::new(); /// Returns OS, CPU, memory, disk, motherboard, GPU, battery, temperature, /// load average, network, and user information. #[tauri::command] -pub fn get_system_info() -> Result { +pub async fn get_system_info() -> Result { + tokio::task::spawn_blocking(get_system_info_blocking) + .await + .map_err(|e| format!("System info task failed: {e}"))? +} + +fn get_system_info_blocking() -> Result { // Reuse the same System instance between calls so CPU and process usage // can be computed from deltas (sysinfo requires at least two refreshes). let sys_mutex = SYS.get_or_init(|| Mutex::new(System::new_all())); @@ -221,6 +229,10 @@ pub fn get_system_info() -> Result { let top_processes = process_list.into_iter().take(10).collect(); + // Collect WMI-based hardware details (best-effort, non-blocking) + let (bios, system_product, ram_slots, cpu_l2_cache_kb, cpu_l3_cache_kb, cpu_socket) = + collect_wmi_details(); + Ok(SystemInfo { os, cpu, @@ -236,5 +248,139 @@ pub fn get_system_info() -> Result { top_processes, uptime_seconds: System::uptime(), boot_time: System::boot_time(), + bios, + system_product, + ram_slots, + cpu_l2_cache_kb, + cpu_l3_cache_kb, + cpu_socket, + }) +} + +/// Collect additional hardware details via PowerShell WMI queries. +/// Returns defaults on failure so the main command never errors from this. +fn collect_wmi_details() -> ( + Option, + Option, + Vec, + Option, + Option, + Option, +) { + let script = r#" +$bios = Get-CimInstance Win32_BIOS | Select-Object Manufacturer, SMBIOSBIOSVersion, ReleaseDate, SerialNumber +$sys = Get-CimInstance Win32_ComputerSystemProduct | Select-Object Vendor, Name, IdentifyingNumber, UUID +$ram = Get-CimInstance Win32_PhysicalMemory | Select-Object BankLabel, DeviceLocator, Manufacturer, PartNumber, SerialNumber, Speed, Capacity, FormFactor, SMBIOSMemoryType +$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 L2CacheSize, L3CacheSize, SocketDesignation + +$result = @{ + bios = @{ + manufacturer = $bios.Manufacturer + version = $bios.SMBIOSBIOSVersion + releaseDate = if ($bios.ReleaseDate) { $bios.ReleaseDate.ToString("yyyy-MM-dd") } else { $null } + serialNumber = $bios.SerialNumber + } + systemProduct = @{ + vendor = $sys.Vendor + model = $sys.Name + serialNumber = $sys.IdentifyingNumber + uuid = $sys.UUID + } + ramSlots = @($ram | ForEach-Object { + @{ + bankLabel = $_.BankLabel + deviceLocator = $_.DeviceLocator + manufacturer = if ($_.Manufacturer) { $_.Manufacturer.Trim() } else { $null } + partNumber = if ($_.PartNumber) { $_.PartNumber.Trim() } else { $null } + serialNumber = if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { $null } + speedMhz = $_.Speed + capacityBytes = $_.Capacity + formFactor = $_.FormFactor + smbiosMemoryType = $_.SMBIOSMemoryType + } }) + cpuL2CacheKb = $cpu.L2CacheSize + cpuL3CacheKb = $cpu.L3CacheSize + cpuSocket = $cpu.SocketDesignation +} + +$result | ConvertTo-Json -Depth 3 -Compress +"#; + + let output = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .output(); + + let output = match output { + Ok(o) if o.status.success() => o, + _ => return (None, None, Vec::new(), None, None, None), + }; + + let json_str = String::from_utf8_lossy(&output.stdout); + let v: serde_json::Value = match serde_json::from_str(&json_str) { + Ok(v) => v, + Err(_) => return (None, None, Vec::new(), None, None, None), + }; + + let bios = Some(BiosInfo { + manufacturer: v["bios"]["manufacturer"].as_str().map(String::from), + version: v["bios"]["version"].as_str().map(String::from), + release_date: v["bios"]["releaseDate"].as_str().map(String::from), + serial_number: v["bios"]["serialNumber"].as_str().map(String::from), + }); + + let system_product = Some(SystemProductInfo { + vendor: v["systemProduct"]["vendor"].as_str().map(String::from), + model: v["systemProduct"]["model"].as_str().map(String::from), + serial_number: v["systemProduct"]["serialNumber"].as_str().map(String::from), + uuid: v["systemProduct"]["uuid"].as_str().map(String::from), + }); + + let form_factor_name = |n: u64| -> String { + match n { + 8 => "DIMM".into(), + 12 => "SO-DIMM".into(), + _ => format!("Type {n}"), + } + }; + + let memory_type_name = |n: u64| -> String { + match n { + 20 => "DDR".into(), + 21 => "DDR2".into(), + 24 => "DDR3".into(), + 26 => "DDR4".into(), + 34 => "DDR5".into(), + _ => format!("Type {n}"), + } + }; + + let ram_slots: Vec = v["ramSlots"] + .as_array() + .map(|arr| { + arr.iter() + .map(|slot| RamSlotInfo { + bank_label: slot["bankLabel"].as_str().map(String::from), + device_locator: slot["deviceLocator"].as_str().map(String::from), + manufacturer: slot["manufacturer"].as_str().map(String::from), + part_number: slot["partNumber"].as_str().map(String::from), + serial_number: slot["serialNumber"].as_str().map(String::from), + speed_mhz: slot["speedMhz"].as_u64(), + capacity_bytes: slot["capacityBytes"].as_u64(), + form_factor: slot["formFactor"] + .as_u64() + .map(|n| form_factor_name(n)), + memory_type: slot["smbiosMemoryType"] + .as_u64() + .map(|n| memory_type_name(n)), + }) + .collect() + }) + .unwrap_or_default(); + + let cpu_l2 = v["cpuL2CacheKb"].as_u64(); + let cpu_l3 = v["cpuL3CacheKb"].as_u64(); + let cpu_socket = v["cpuSocket"].as_str().map(String::from); + + (bios, system_product, ram_slots, cpu_l2, cpu_l3, cpu_socket) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89deabb..1277f67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,8 +8,10 @@ //! //! - `types` - Data structures for settings and system information //! - `commands` - Tauri command handlers exposed to the frontend +//! - `mcp` - Model Context Protocol server for remote LLM access mod commands; +mod mcp; mod services; mod types; @@ -26,6 +28,23 @@ pub use commands::*; /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Load settings and start MCP server in background if enabled + if let Ok(settings) = get_settings() { + if settings.agent.mcp_server_enabled { + if let Some(api_key) = settings.agent.mcp_api_key { + eprintln!("[MCP] Starting server on port {}", settings.agent.mcp_port); + mcp::start_mcp_server_background( + settings.agent.mcp_port, + api_key, + settings.agent.tavily_api_key, + settings.agent.searxng_url, + ); + } else { + eprintln!("[MCP] Server enabled but no API key configured"); + } + } + } + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) @@ -54,16 +73,28 @@ pub fn run() { commands::delete_script, commands::run_script, // Service commands + commands::list_usb_drives, commands::get_service_definitions, commands::get_service_presets, commands::validate_service_requirements, commands::get_service_run_state, commands::run_services, commands::cancel_service_run, + commands::pause_service_run, + commands::resume_service_run, commands::get_service_report, commands::list_service_reports, commands::delete_report, commands::clear_all_reports, + // Report editing commands (agent) + commands::edit_report_finding, + commands::add_report_finding, + commands::remove_report_finding, + commands::set_report_summary, + commands::set_service_analysis, + commands::set_report_health_score, + commands::get_report_statistics, + commands::generate_report_pdf, // Service presets management commands::save_service_preset, commands::delete_service_preset, @@ -98,6 +129,7 @@ pub fn run() { commands::get_startup_items, commands::toggle_startup_item, commands::delete_startup_item, + commands::open_startup_item_location, // Event log commands commands::get_event_log_sources, commands::get_event_logs, @@ -107,7 +139,51 @@ pub fn run() { commands::get_bsod_history, commands::get_bsod_details, commands::get_bsod_stats, - commands::delete_crash_dump + commands::delete_crash_dump, + // Agent commands + commands::queue_agent_command, + commands::execute_agent_command, + commands::get_pending_commands, + commands::clear_pending_commands, + commands::approve_command, + commands::reject_command, + commands::search_tavily, + commands::search_searxng, + commands::get_agent_settings, + commands::get_command_history, + commands::agent_read_file, + commands::agent_write_file, + commands::agent_list_dir, + commands::agent_move_file, + commands::agent_copy_file, + commands::agent_edit_file, + commands::agent_grep, + commands::agent_glob, + commands::list_instruments, + commands::list_agent_programs, + commands::agent_find_exe, + // File attachment commands + commands::save_uploaded_file, + commands::generate_agent_file, + commands::read_file_content, + commands::read_file_binary, + commands::get_file_info, + commands::list_agent_files, + commands::delete_agent_file, + commands::validate_filesystem_path, + commands::read_filesystem_file, + // Conversation commands + commands::create_conversation, + commands::list_conversations, + commands::get_conversation, + commands::save_conversation_messages, + commands::update_conversation_title, + commands::delete_conversation, + // Disk health commands + commands::get_disk_health, + // Restore point commands + commands::get_restore_points, + commands::create_restore_point ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs new file mode 100644 index 0000000..b29a13c --- /dev/null +++ b/src-tauri/src/mcp/mod.rs @@ -0,0 +1,9 @@ +//! MCP Server Module +//! +//! Implements a Model Context Protocol server for remote LLM control. +//! Uses rmcp crate with streamable HTTP transport. + +mod server; +mod tools; + +pub use server::start_mcp_server_background; diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs new file mode 100644 index 0000000..cab6cf2 --- /dev/null +++ b/src-tauri/src/mcp/server.rs @@ -0,0 +1,712 @@ +//! MCP HTTP Server +//! +//! Implements full MCP JSON-RPC protocol over HTTP transport. +//! Handles tools/list and tools/call methods for external LLM control. + +use std::net::SocketAddr; +use std::sync::Arc; + +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use serde::{Deserialize, Serialize}; +use tokio::net::TcpListener; + +use super::tools::RustServiceTools; + +type BoxBody = Full; + +// ============================================================================= +// JSON-RPC Types +// ============================================================================= + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct JsonRpcRequest { + jsonrpc: String, + method: String, + #[serde(default)] + params: serde_json::Value, + id: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + id: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i32, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +impl JsonRpcResponse { + fn success(result: serde_json::Value, id: serde_json::Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + result: Some(result), + error: None, + id, + } + } + + fn error(code: i32, message: &str, id: serde_json::Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + result: None, + error: Some(JsonRpcError { + code, + message: message.to_string(), + data: None, + }), + id, + } + } +} + +// ============================================================================= +// Tool Definitions for tools/list +// ============================================================================= + +fn get_tool_definitions() -> serde_json::Value { + serde_json::json!({ + "tools": [ + { + "name": "execute_command", + "description": "Execute a PowerShell command on the system. Returns stdout, stderr, and exit code.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to execute" + }, + "reason": { + "type": "string", + "description": "Brief explanation of why this command is needed" + } + }, + "required": ["command", "reason"] + } + }, + { + "name": "read_file", + "description": "Read the contents of a file. Use this to examine configuration files, logs, or other text files.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Full path to the file" + } + }, + "required": ["path"] + } + }, + { + "name": "write_file", + "description": "Write content to a file. Creates the file if it doesn't exist.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Full path to the file" + }, + "content": { + "type": "string", + "description": "Content to write" + } + }, + "required": ["path", "content"] + } + }, + { + "name": "list_dir", + "description": "List files and directories in a specific path. Use this to explore the file system.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to list content for" + } + }, + "required": ["path"] + } + }, + { + "name": "move_file", + "description": "Move or rename a file from source to destination.", + "inputSchema": { + "type": "object", + "properties": { + "src": { + "type": "string", + "description": "Source file path" + }, + "dest": { + "type": "string", + "description": "Destination file path" + } + }, + "required": ["src", "dest"] + } + }, + { + "name": "copy_file", + "description": "Copy a file from source to destination.", + "inputSchema": { + "type": "object", + "properties": { + "src": { + "type": "string", + "description": "Source file path" + }, + "dest": { + "type": "string", + "description": "Destination file path" + } + }, + "required": ["src", "dest"] + } + }, + { + "name": "get_system_info", + "description": "Get detailed system information including OS version, CPU, memory, disks, and hostname.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "search_web", + "description": "Search the web for information. Returns search results with titles, URLs, and snippets.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query" + } + }, + "required": ["query"] + } + }, + { + "name": "list_programs", + "description": "List all portable programs available in the data/programs folder.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "list_instruments", + "description": "List available custom instruments (scripts) that can be run.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "name": "run_instrument", + "description": "Run a custom instrument (script) by name.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the instrument to run" + }, + "args": { + "type": "string", + "description": "Optional arguments to pass to the instrument" + } + }, + "required": ["name"] + } + } + ] + }) +} + +// ============================================================================= +// Tool Dispatch +// ============================================================================= + +async fn dispatch_tool_call( + tools: &RustServiceTools, + name: &str, + arguments: serde_json::Value, +) -> Result { + match name { + "execute_command" => { + let command = arguments + .get("command") + .and_then(|v| v.as_str()) + .ok_or("Missing 'command' argument")?; + let reason = arguments + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("MCP request"); + + let result = tools + .execute_command(command.to_string(), reason.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "read_file" => { + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' argument")?; + let offset = arguments + .get("offset") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + let limit = arguments + .get("limit") + .and_then(|v| v.as_u64()) + .map(|v| v as usize); + let line_numbers = arguments.get("line_numbers").and_then(|v| v.as_bool()); + + let result = tools + .read_file(path.to_string(), offset, limit, line_numbers) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "write_file" => { + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' argument")?; + let content = arguments + .get("content") + .and_then(|v| v.as_str()) + .ok_or("Missing 'content' argument")?; + + let result = tools + .write_file(path.to_string(), content.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "list_dir" => { + let path = arguments + .get("path") + .and_then(|v| v.as_str()) + .ok_or("Missing 'path' argument")?; + + let result = tools + .list_dir(path.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "move_file" => { + let src = arguments + .get("src") + .and_then(|v| v.as_str()) + .ok_or("Missing 'src' argument")?; + let dest = arguments + .get("dest") + .and_then(|v| v.as_str()) + .ok_or("Missing 'dest' argument")?; + + let result = tools + .move_file(src.to_string(), dest.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "copy_file" => { + let src = arguments + .get("src") + .and_then(|v| v.as_str()) + .ok_or("Missing 'src' argument")?; + let dest = arguments + .get("dest") + .and_then(|v| v.as_str()) + .ok_or("Missing 'dest' argument")?; + + let result = tools + .copy_file(src.to_string(), dest.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "get_system_info" => { + let result = tools + .get_system_info() + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "search_web" => { + let query = arguments + .get("query") + .and_then(|v| v.as_str()) + .ok_or("Missing 'query' argument")?; + + let result = tools + .search_web(query.to_string()) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "list_programs" => { + let result = tools + .list_programs() + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "list_instruments" => { + let result = tools + .list_instruments() + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + "run_instrument" => { + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing 'name' argument")?; + let args = arguments + .get("args") + .and_then(|v| v.as_str()) + .map(String::from); + + let result = tools + .run_instrument(name.to_string(), args) + .await + .map_err(|e| format!("Tool error: {:?}", e))?; + + Ok(call_tool_result_to_json(result)) + } + _ => Err(format!("Unknown tool: {}", name)), + } +} + +/// Convert rmcp CallToolResult to JSON +fn call_tool_result_to_json(result: rmcp::model::CallToolResult) -> serde_json::Value { + let content: Vec = result + .content + .iter() + .map(|c| { + // rmcp Content is Annotated, extract the raw content + // The raw field contains the actual content + serde_json::json!({ + "type": "text", + "text": format!("{:?}", c.raw) + }) + }) + .collect(); + + serde_json::json!({ + "content": content, + "isError": result.is_error.unwrap_or(false) + }) +} + +// ============================================================================= +// HTTP Request Handler +// ============================================================================= + +async fn handle_mcp_request( + req: Request, + api_key: Arc, + tools: Arc, +) -> Result, hyper::Error> { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + + // Health check endpoint (no auth required) + if path == "/" || path == "/health" { + let response = serde_json::json!({ + "status": "ok", + "service": "RustService MCP Server", + "version": "1.0.0", + "tools_available": 11 + }); + return Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from(response.to_string()))) + .unwrap()); + } + + // Handle CORS preflight + if method == Method::OPTIONS { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("Access-Control-Allow-Origin", "*") + .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header( + "Access-Control-Allow-Headers", + "Authorization, Content-Type", + ) + .body(BoxBody::new(Bytes::new())) + .unwrap()); + } + + // Check authorization for /mcp endpoint + let auth_header = req.headers().get("authorization"); + let expected = format!("Bearer {}", api_key); + + if auth_header.map(|h| h.to_str().ok()) != Some(Some(expected.as_str())) { + eprintln!("MCP: Unauthorized request to {}", path); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + r#"{"error":"Unauthorized - Bearer token required"}"#, + ))) + .unwrap()); + } + + // Only accept POST to /mcp + if path != "/mcp" { + return Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + r#"{"error":"Not Found - use POST /mcp"}"#, + ))) + .unwrap()); + } + + if method != Method::POST { + return Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + r#"{"error":"Method not allowed - use POST"}"#, + ))) + .unwrap()); + } + + // Collect request body + let body_bytes = match req.collect().await { + Ok(collected) => collected.to_bytes(), + Err(e) => { + eprintln!("MCP: Failed to read request body: {}", e); + let response = JsonRpcResponse::error( + -32700, + "Failed to read request body", + serde_json::Value::Null, + ); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + serde_json::to_string(&response).unwrap(), + ))) + .unwrap()); + } + }; + + // Parse JSON-RPC request + let rpc_request: JsonRpcRequest = match serde_json::from_slice(&body_bytes) { + Ok(req) => req, + Err(e) => { + eprintln!("MCP: Failed to parse JSON-RPC: {}", e); + let response = JsonRpcResponse::error( + -32700, + "Parse error - invalid JSON", + serde_json::Value::Null, + ); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + serde_json::to_string(&response).unwrap(), + ))) + .unwrap()); + } + }; + + eprintln!( + "MCP: Received method '{}' id={}", + rpc_request.method, rpc_request.id + ); + + // Handle MCP methods + let response = match rpc_request.method.as_str() { + "initialize" => { + // MCP initialization - return server capabilities + JsonRpcResponse::success( + serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": { + "listChanged": true + } + }, + "serverInfo": { + "name": "RustService MCP", + "version": "1.0.0" + } + }), + rpc_request.id, + ) + } + "tools/list" => { + // Return list of available tools + JsonRpcResponse::success(get_tool_definitions(), rpc_request.id) + } + "tools/call" => { + // Extract tool name and arguments + let tool_name = rpc_request + .params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let arguments = rpc_request + .params + .get("arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + + eprintln!("MCP: Calling tool '{}' with args: {}", tool_name, arguments); + + match dispatch_tool_call(&tools, tool_name, arguments).await { + Ok(result) => JsonRpcResponse::success(result, rpc_request.id), + Err(e) => JsonRpcResponse::error(-32603, &e, rpc_request.id), + } + } + "notifications/initialized" => { + // Client notification that initialization is complete - no response needed + // But we return success anyway for non-notification requests + JsonRpcResponse::success(serde_json::json!({}), rpc_request.id) + } + _ => { + eprintln!("MCP: Method not found: {}", rpc_request.method); + JsonRpcResponse::error( + -32601, + &format!("Method not found: {}", rpc_request.method), + rpc_request.id, + ) + } + }; + + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", "*") + .body(BoxBody::new(Bytes::from( + serde_json::to_string(&response).unwrap(), + ))) + .unwrap()) +} + +// ============================================================================= +// Server Lifecycle +// ============================================================================= + +/// Run the MCP server with HTTP transport +pub async fn run_mcp_server_http( + port: u16, + api_key: String, + tavily_key: Option, + searxng_url: Option, +) { + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + eprintln!("Starting MCP HTTP server on http://0.0.0.0:{}/mcp", port); + + let tools = Arc::new(RustServiceTools::with_settings(tavily_key, searxng_url)); + let api_key = Arc::new(api_key); + + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind MCP server to {}: {}", addr, e); + return; + } + }; + + eprintln!("MCP HTTP server listening on http://{}/mcp", addr); + eprintln!("Available tools: execute_command, read_file, write_file, list_dir, move_file, copy_file, get_system_info, search_web, list_programs, list_instruments, run_instrument"); + + loop { + let (stream, remote_addr) = match listener.accept().await { + Ok(conn) => conn, + Err(e) => { + eprintln!("Failed to accept connection: {}", e); + continue; + } + }; + + let io = TokioIo::new(stream); + let api_key = api_key.clone(); + let tools = tools.clone(); + + tokio::spawn(async move { + let service = service_fn(move |req| { + let api_key = api_key.clone(); + let tools = tools.clone(); + async move { handle_mcp_request(req, api_key, tools).await } + }); + + if let Err(e) = http1::Builder::new().serve_connection(io, service).await { + // Connection errors are usually just clients disconnecting + eprintln!("Connection from {} ended: {}", remote_addr, e); + } + }); + } +} + +/// Start the MCP server in a background thread +pub fn start_mcp_server_background( + port: u16, + api_key: String, + tavily_key: Option, + searxng_url: Option, +) { + std::thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("Failed to create tokio runtime for MCP server: {}", e); + return; + } + }; + + rt.block_on(run_mcp_server_http(port, api_key, tavily_key, searxng_url)); + }); +} diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs new file mode 100644 index 0000000..97b402e --- /dev/null +++ b/src-tauri/src/mcp/tools.rs @@ -0,0 +1,1093 @@ +//! MCP Tool Definitions +//! +//! Defines tools exposed via MCP. Tools implement core functionality directly +//! to avoid complex inter-module dependencies. + +use glob; +use regex; +use rmcp::model::{CallToolResult, Content}; +use rmcp::tool; +use std::fs; +use std::path::Path; +use std::process::Command; +use sysinfo::System; + +// ============================================================================= +// Types +// ============================================================================= + +struct CommandResult { + exit_code: i32, + stdout: String, + stderr: String, +} + +struct FileEntry { + name: String, + is_dir: bool, + size: Option, +} + +struct ProgramInfo { + name: String, + path: String, + executables: Vec, +} + +struct InstrumentInfo { + name: String, + path: String, + extension: String, + description: String, +} + +// ============================================================================= +// MCP Tool Handler +// ============================================================================= + +/// RustService MCP tool handler +#[derive(Clone)] +pub struct RustServiceTools { + /// Tavily API key for web search + pub tavily_api_key: Option, + /// SearXNG URL for web search + pub searxng_url: Option, +} + +impl RustServiceTools { + #[allow(dead_code)] + pub fn new() -> Self { + Self { + tavily_api_key: None, + searxng_url: None, + } + } + + pub fn with_settings(tavily_api_key: Option, searxng_url: Option) -> Self { + Self { + tavily_api_key, + searxng_url, + } + } +} + +// ============================================================================= +// Internal Helper Functions +// ============================================================================= + +fn execute_shell_command(command: &str) -> Result { + #[cfg(windows)] + { + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", command]) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + Ok(CommandResult { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } + + #[cfg(not(windows))] + { + let output = Command::new("sh") + .args(["-c", command]) + .output() + .map_err(|e| format!("Failed to execute command: {}", e))?; + + Ok(CommandResult { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + +fn read_file_contents(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e)) +} + +fn list_directory(path: &str) -> Result, String> { + let path = Path::new(path); + if !path.exists() { + return Err("Path does not exist".to_string()); + } + if !path.is_dir() { + return Err("Path is not a directory".to_string()); + } + + let entries = fs::read_dir(path).map_err(|e| format!("Failed to read directory: {}", e))?; + + let mut result = Vec::new(); + for entry in entries { + if let Ok(entry) = entry { + let metadata = entry.metadata().ok(); + result.push(FileEntry { + name: entry.file_name().to_string_lossy().to_string(), + is_dir: metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false), + size: metadata.and_then(|m| if m.is_file() { Some(m.len()) } else { None }), + }); + } + } + + Ok(result) +} + +fn get_data_dir() -> std::path::PathBuf { + // In development, data folder is in src-tauri/data + // In production, it's next to the executable + if cfg!(debug_assertions) { + std::path::PathBuf::from("data") + } else { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("data") + } +} + +fn list_programs_in_folder() -> Result, String> { + let programs_dir = get_data_dir().join("programs"); + if !programs_dir.exists() { + return Ok(Vec::new()); + } + + let mut programs = Vec::new(); + + // Iterate over top-level folders in programs/ + if let Ok(entries) = fs::read_dir(&programs_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Find executables in this folder + let mut executables = Vec::new(); + if let Ok(sub_entries) = fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + let sub_path = sub_entry.path(); + if sub_path.is_file() { + if let Some(ext) = sub_path.extension() { + if ext == "exe" { + executables.push( + sub_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + ); + } + } + } + } + } + + programs.push(ProgramInfo { + name, + path: path.to_string_lossy().to_string(), + executables, + }); + } + } + } + + Ok(programs) +} + +fn list_instruments_in_folder() -> Result, String> { + let instruments_dir = get_data_dir().join("instruments"); + if !instruments_dir.exists() { + return Ok(Vec::new()); + } + + let mut instruments = Vec::new(); + let valid_extensions = ["ps1", "bat", "cmd", "py", "js", "exe"]; + + if let Ok(entries) = fs::read_dir(&instruments_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if valid_extensions.contains(&ext_str.as_str()) { + let name = path + .file_stem() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let description = format!("Custom {} instrument", ext_str.to_uppercase()); + instruments.push(InstrumentInfo { + name, + path: path.to_string_lossy().to_string(), + extension: ext_str, + description, + }); + } + } + } + } + } + + Ok(instruments) +} + +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +// ============================================================================= +// MCP Tools +// ============================================================================= + +#[tool(tool_box)] +impl RustServiceTools { + /// Execute a PowerShell command on the system + #[tool( + description = "Execute a PowerShell command on the system. Returns stdout, stderr, and exit code." + )] + pub async fn execute_command( + &self, + #[tool(param)] command: String, + #[tool(param)] reason: String, + ) -> Result { + eprintln!("MCP execute_command: {} ({})", command, reason); + + match execute_shell_command(&command) { + Ok(result) => { + let output = if result.exit_code == 0 { + format!( + "Exit code: {}\n\nOutput:\n{}", + result.exit_code, result.stdout + ) + } else { + format!( + "Exit code: {}\n\nStdout:\n{}\n\nStderr:\n{}", + result.exit_code, result.stdout, result.stderr + ) + }; + Ok(CallToolResult::success(vec![Content::text(output)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {}", + e + ))])), + } + } + + /// Read the contents of a file with optional pagination + #[tool( + description = "Read the contents of a file with optional line numbers and pagination. Use this to examine configuration files, logs, or other text files." + )] + pub async fn read_file( + &self, + #[tool(param)] path: String, + #[tool(param)] offset: Option, + #[tool(param)] limit: Option, + #[tool(param)] line_numbers: Option, + ) -> Result { + eprintln!("MCP read_file: {}", path); + + match read_file_contents(&path) { + Ok(content) => { + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + let offset_val = offset.unwrap_or(0); + let limit_val = limit.unwrap_or(total_lines); + let end = std::cmp::min(offset_val + limit_val, total_lines); + + let selected: Vec<&str> = if offset_val < total_lines { + lines[offset_val..end].to_vec() + } else { + Vec::new() + }; + + let show_line_numbers = line_numbers.unwrap_or(true); + let formatted = if show_line_numbers { + selected + .iter() + .enumerate() + .map(|(idx, line)| format!("{:4}| {}", offset_val + idx + 1, line)) + .collect::>() + .join("\n") + } else { + selected.join("\n") + }; + + let has_more = end < total_lines; + let result = if has_more { + format!("{}\n\n[{} more lines...]", formatted, total_lines - end) + } else { + formatted + }; + + Ok(CallToolResult::success(vec![Content::text(result)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error reading {}: {}", + path, e + ))])), + } + } + + /// Edit a file by replacing old_string with new_string + #[tool( + description = "Edit a file by replacing old_string with new_string. The old_string must be unique in the file unless all=true is specified. Use this for targeted edits instead of rewriting entire files." + )] + pub async fn edit_file( + &self, + #[tool(param)] path: String, + #[tool(param)] old_string: String, + #[tool(param)] new_string: String, + #[tool(param)] all: Option, + ) -> Result { + eprintln!("MCP edit_file: {}", path); + + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Error reading file: {}", + e + ))])); + } + }; + + let replace_all = all.unwrap_or(false); + + if !text.contains(&old_string) { + return Ok(CallToolResult::error(vec![Content::text( + "Error: old_string not found in file", + )])); + } + + let count = text.matches(&old_string).count(); + if !replace_all && count > 1 { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Error: old_string appears {} times, must be unique (use all=true)", + count + ))])); + } + + let replacement = if replace_all { + text.replace(&old_string, &new_string) + } else { + text.replacen(&old_string, &new_string, 1) + }; + + match fs::write(&path, replacement) { + Ok(_) => { + let replacements = if replace_all { count } else { 1 }; + Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully made {} replacement{} in {}", + replacements, + if replacements > 1 { "s" } else { "" }, + path + ))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error writing file: {}", + e + ))])), + } + } + + /// Search for a regex pattern across files + #[tool( + description = "Search for a regex pattern across files in a directory. Returns matching lines with file paths and line numbers." + )] + pub async fn grep( + &self, + #[tool(param)] pattern: String, + #[tool(param)] path: Option, + #[tool(param)] file_pattern: Option, + #[tool(param)] max_results: Option, + ) -> Result { + eprintln!("MCP grep: {}", pattern); + + let regex = match regex::Regex::new(&pattern) { + Ok(r) => r, + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Invalid regex pattern: {}", + e + ))])); + } + }; + + let base_path = path.unwrap_or_else(|| ".".to_string()); + let max = max_results.unwrap_or(50); + let glob_pat = file_pattern.unwrap_or_else(|| "*".to_string()); + let full_pattern = format!("{}/**/ {}", base_path, glob_pat); + + let mut results = Vec::new(); + + let glob_result = match glob::glob(&full_pattern) { + Ok(g) => g, + Err(_) => { + return Ok(CallToolResult::success(vec![Content::text(format!( + "Invalid glob pattern: '{}'", + pattern + ))])); + } + }; + + for entry in glob_result.flatten() { + if !entry.is_file() { + continue; + } + + if let Ok(content) = fs::read_to_string(&entry) { + for (line_num, line) in content.lines().enumerate() { + if regex.is_match(line) { + results.push(format!( + "{}:{}: {}", + entry.to_string_lossy(), + line_num + 1, + line + )); + + if results.len() >= max { + break; + } + } + } + } + + if results.len() >= max { + break; + } + } + + if results.is_empty() { + Ok(CallToolResult::success(vec![Content::text(format!( + "No matches found for pattern '{}'", + pattern + ))])) + } else { + Ok(CallToolResult::success(vec![Content::text(format!( + "Found {} match{}:\n\n{}", + results.len(), + if results.len() > 1 { "es" } else { "" }, + results.join("\n") + ))])) + } + } + + /// Find files matching a glob pattern + #[tool( + description = "Find files matching a glob pattern, sorted by modification time (newest first)." + )] + pub async fn glob( + &self, + #[tool(param)] pattern: String, + #[tool(param)] path: Option, + #[tool(param)] limit: Option, + ) -> Result { + eprintln!("MCP glob: {}", pattern); + + let base_path = path.unwrap_or_else(|| ".".to_string()); + let max = limit.unwrap_or(100); + let full_pattern = format!("{}/{}", base_path, pattern); + + let mut files: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new(); + + let glob_result = match glob::glob(&full_pattern) { + Ok(g) => g, + Err(_) => { + return Ok(CallToolResult::success(vec![Content::text(format!( + "Invalid glob pattern: '{}'", + pattern + ))])); + } + }; + + for entry in glob_result.flatten() { + if let Ok(metadata) = fs::metadata(&entry) { + let mtime = metadata + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let size = metadata.len(); + files.push((entry, mtime, size)); + } + } + + // Sort by modification time (newest first) + files.sort_by(|a, b| b.1.cmp(&a.1)); + + let formatted: Vec = files + .into_iter() + .take(max) + .map(|(path, _mtime, size)| { + format!("{} ({})", path.to_string_lossy(), format_bytes(size)) + }) + .collect(); + + if formatted.is_empty() { + Ok(CallToolResult::success(vec![Content::text(format!( + "No files found matching pattern '{}'", + pattern + ))])) + } else { + Ok(CallToolResult::success(vec![Content::text(format!( + "Found {} file{}:\n\n{}", + formatted.len(), + if formatted.len() > 1 { "s" } else { "" }, + formatted.join("\n") + ))])) + } + } + + /// Write content to a file + #[tool(description = "Write content to a file. Creates the file if it doesn't exist.")] + pub async fn write_file( + &self, + #[tool(param)] path: String, + #[tool(param)] content: String, + ) -> Result { + eprintln!("MCP write_file: {}", path); + + // Create parent directories if they don't exist + if let Some(parent) = Path::new(&path).parent() { + if !parent.exists() { + if let Err(e) = fs::create_dir_all(parent) { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Error creating directories: {}", + e + ))])); + } + } + } + + match fs::write(&path, &content) { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Successfully wrote {} bytes to {}", + content.len(), + path + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error writing {}: {}", + path, e + ))])), + } + } + + /// List files and directories in a path + #[tool( + description = "List files and directories in a specific path. Use this to explore the file system." + )] + pub async fn list_dir( + &self, + #[tool(param)] path: String, + ) -> Result { + eprintln!("MCP list_dir: {}", path); + + match list_directory(&path) { + Ok(entries) => { + let listing: Vec = entries + .iter() + .map(|e| { + let type_indicator = if e.is_dir { "DIR" } else { "FILE" }; + let size = e + .size + .map(|s| format!(" ({})", format_bytes(s))) + .unwrap_or_default(); + format!("[{}] {}{}", type_indicator, e.name, size) + }) + .collect(); + + let output = if listing.is_empty() { + format!("Directory {} is empty", path) + } else { + format!( + "Contents of {} ({} items):\n{}", + path, + listing.len(), + listing.join("\n") + ) + }; + + Ok(CallToolResult::success(vec![Content::text(output)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error listing {}: {}", + path, e + ))])), + } + } + + /// Move or rename a file + #[tool(description = "Move or rename a file from source to destination.")] + pub async fn move_file( + &self, + #[tool(param)] src: String, + #[tool(param)] dest: String, + ) -> Result { + eprintln!("MCP move_file: {} -> {}", src, dest); + + match fs::rename(&src, &dest) { + Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!( + "Moved {} to {}", + src, dest + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error moving file: {}", + e + ))])), + } + } + + /// Copy a file + #[tool(description = "Copy a file from source to destination.")] + pub async fn copy_file( + &self, + #[tool(param)] src: String, + #[tool(param)] dest: String, + ) -> Result { + eprintln!("MCP copy_file: {} -> {}", src, dest); + + match fs::copy(&src, &dest) { + Ok(bytes) => Ok(CallToolResult::success(vec![Content::text(format!( + "Copied {} ({}) to {}", + src, + format_bytes(bytes), + dest + ))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error copying file: {}", + e + ))])), + } + } + + /// Get system information + #[tool( + description = "Get detailed system information including OS version, CPU, memory, disks, and hostname." + )] + pub async fn get_system_info(&self) -> Result { + eprintln!("MCP get_system_info"); + + // Get comprehensive system info using sysinfo crate + let mut sys = System::new_all(); + sys.refresh_all(); + + let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string()); + let os_name = System::name().unwrap_or_else(|| "unknown".to_string()); + let os_version = System::os_version().unwrap_or_else(|| "unknown".to_string()); + let kernel_version = System::kernel_version().unwrap_or_else(|| "unknown".to_string()); + + // CPU info + let cpu_count = sys.cpus().len(); + let cpu_name = sys + .cpus() + .first() + .map(|c| c.brand().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // Memory info + let total_memory = sys.total_memory(); + let used_memory = sys.used_memory(); + let available_memory = sys.available_memory(); + + // Disk info + let mut disk_info = Vec::new(); + for disk in sysinfo::Disks::new_with_refreshed_list().iter() { + let name = disk.name().to_string_lossy().to_string(); + let mount = disk.mount_point().to_string_lossy().to_string(); + let total = disk.total_space(); + let available = disk.available_space(); + let used = total - available; + disk_info.push(format!( + " {} ({}): {} used of {} ({} available)", + mount, + name, + format_bytes(used), + format_bytes(total), + format_bytes(available) + )); + } + + let info = format!( + "=== System Information ===\n\n\ + Hostname: {}\n\ + OS: {} {}\n\ + Kernel: {}\n\n\ + === CPU ===\n\ + Processor: {}\n\ + Cores: {}\n\n\ + === Memory ===\n\ + Total: {}\n\ + Used: {}\n\ + Available: {}\n\n\ + === Disks ===\n{}", + hostname, + os_name, + os_version, + kernel_version, + cpu_name, + cpu_count, + format_bytes(total_memory), + format_bytes(used_memory), + format_bytes(available_memory), + disk_info.join("\n") + ); + + Ok(CallToolResult::success(vec![Content::text(info)])) + } + + /// Search the web using configured search provider + #[tool( + description = "Search the web for information. Returns search results with titles, URLs, and snippets." + )] + pub async fn search_web( + &self, + #[tool(param)] query: String, + ) -> Result { + eprintln!("MCP search_web: {}", query); + + // Try Tavily first + if let Some(api_key) = &self.tavily_api_key { + if !api_key.is_empty() { + match search_tavily(&query, api_key).await { + Ok(results) => { + if results.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No search results found for '{}'", + query + ))])); + } + + let formatted: Vec = results + .iter() + .enumerate() + .map(|(i, r)| { + format!( + "{}. {}\n URL: {}\n {}", + i + 1, + r.title, + r.url, + r.snippet + ) + }) + .collect(); + + return Ok(CallToolResult::success(vec![Content::text(format!( + "Search results for '{}':\n\n{}", + query, + formatted.join("\n\n") + ))])); + } + Err(e) => { + eprintln!("Tavily search failed: {}", e); + // Fall through to try SearXNG + } + } + } + } + + // Try SearXNG + if let Some(url) = &self.searxng_url { + if !url.is_empty() { + match search_searxng(&query, url).await { + Ok(results) => { + if results.is_empty() { + return Ok(CallToolResult::success(vec![Content::text(format!( + "No search results found for '{}'", + query + ))])); + } + + let formatted: Vec = results + .iter() + .enumerate() + .map(|(i, r)| { + format!( + "{}. {}\n URL: {}\n {}", + i + 1, + r.title, + r.url, + r.snippet + ) + }) + .collect(); + + return Ok(CallToolResult::success(vec![Content::text(format!( + "Search results for '{}':\n\n{}", + query, + formatted.join("\n\n") + ))])); + } + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Search failed: {}", + e + ))])); + } + } + } + } + + Ok(CallToolResult::error(vec![Content::text( + "No search provider configured. Set up Tavily API key or SearXNG URL in settings.", + )])) + } + + /// List portable programs + #[tool(description = "List all portable programs available in the data/programs folder.")] + pub async fn list_programs(&self) -> Result { + eprintln!("MCP list_programs"); + + match list_programs_in_folder() { + Ok(programs) => { + if programs.is_empty() { + return Ok(CallToolResult::success(vec![Content::text( + "No programs found in data/programs folder.", + )])); + } + + let formatted: Vec = programs + .iter() + .map(|p| { + let exes = if p.executables.is_empty() { + "No executables found".to_string() + } else { + p.executables.join(", ") + }; + format!("- {}\n Path: {}\n Executables: {}", p.name, p.path, exes) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text(format!( + "Available programs ({}):\n\n{}", + programs.len(), + formatted.join("\n\n") + ))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error listing programs: {}", + e + ))])), + } + } + + /// List custom instruments + #[tool(description = "List available custom instruments (scripts) that can be run.")] + pub async fn list_instruments(&self) -> Result { + eprintln!("MCP list_instruments"); + + match list_instruments_in_folder() { + Ok(instruments) => { + if instruments.is_empty() { + return Ok(CallToolResult::success(vec![Content::text( + "No instruments found in data/instruments folder.", + )])); + } + + let formatted: Vec = instruments + .iter() + .map(|i| { + format!( + "- {} ({})\n Path: {}\n {}", + i.name, i.extension, i.path, i.description + ) + }) + .collect(); + + Ok(CallToolResult::success(vec![Content::text(format!( + "Available instruments ({}):\n\n{}", + instruments.len(), + formatted.join("\n\n") + ))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error listing instruments: {}", + e + ))])), + } + } + + /// Run a custom instrument + #[tool(description = "Run a custom instrument (script) by name.")] + pub async fn run_instrument( + &self, + #[tool(param)] name: String, + #[tool(param)] args: Option, + ) -> Result { + eprintln!("MCP run_instrument: {} args={:?}", name, args); + + // Find the instrument + let instruments = match list_instruments_in_folder() { + Ok(list) => list, + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Error finding instruments: {}", + e + ))])); + } + }; + + let instrument = instruments + .iter() + .find(|i| i.name.to_lowercase() == name.to_lowercase()); + + let instrument = match instrument { + Some(i) => i, + None => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Instrument '{}' not found. Use list_instruments to see available options.", + name + ))])); + } + }; + + // Construct command based on extension + let args_str = args.unwrap_or_default(); + let command = match instrument.extension.as_str() { + "ps1" => format!( + "powershell -ExecutionPolicy Bypass -File \"{}\" {}", + instrument.path, args_str + ), + "bat" | "cmd" => format!("\"{}\" {}", instrument.path, args_str), + "exe" => format!("\"{}\" {}", instrument.path, args_str), + "py" => format!("python \"{}\" {}", instrument.path, args_str), + "js" => format!("node \"{}\" {}", instrument.path, args_str), + _ => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "Unsupported instrument extension: {}", + instrument.extension + ))])); + } + }; + + // Execute the instrument + match execute_shell_command(&command) { + Ok(result) => { + let output = if result.exit_code == 0 { + format!( + "Instrument '{}' completed successfully.\n\nOutput:\n{}", + name, result.stdout + ) + } else { + format!( + "Instrument '{}' failed (exit code {}).\n\nStdout:\n{}\n\nStderr:\n{}", + name, result.exit_code, result.stdout, result.stderr + ) + }; + Ok(CallToolResult::success(vec![Content::text(output)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error running instrument '{}': {}", + name, e + ))])), + } + } +} + +// ============================================================================= +// Search Implementation +// ============================================================================= + +struct SearchResult { + title: String, + url: String, + snippet: String, +} + +async fn search_tavily(query: &str, api_key: &str) -> Result, String> { + let client = reqwest::Client::new(); + + let response = client + .post("https://api.tavily.com/search") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "api_key": api_key, + "query": query, + "search_depth": "basic", + "max_results": 5 + })) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Tavily API error: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let results = data["results"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| SearchResult { + title: r["title"].as_str().unwrap_or("").to_string(), + url: r["url"].as_str().unwrap_or("").to_string(), + snippet: r["content"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(results) +} + +async fn search_searxng(query: &str, instance_url: &str) -> Result, String> { + let client = reqwest::Client::new(); + let encoded_query = urlencoding::encode(query); + let url = format!( + "{}/search?q={}&format=json&categories=general", + instance_url.trim_end_matches('/'), + encoded_query + ); + + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("SearXNG API error: {}", response.status())); + } + + let data: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let results = data["results"] + .as_array() + .map(|arr| { + arr.iter() + .take(5) + .map(|r| SearchResult { + title: r["title"].as_str().unwrap_or("").to_string(), + url: r["url"].as_str().unwrap_or("").to_string(), + snippet: r["content"].as_str().unwrap_or("").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(results) +} diff --git a/src-tauri/src/services/adwcleaner.rs b/src-tauri/src/services/adwcleaner.rs index ce82d02..fa6191e 100644 --- a/src-tauri/src/services/adwcleaner.rs +++ b/src-tauri/src/services/adwcleaner.rs @@ -34,6 +34,8 @@ impl Service for AdwCleanerService { required_programs: vec!["adwcleaner".to_string()], options: vec![], icon: "sparkles".to_string(), + exclusive_resources: vec!["filesystem-scan".to_string()], + dependencies: vec![], } } @@ -80,6 +82,7 @@ impl Service for AdwCleanerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -95,6 +98,7 @@ impl Service for AdwCleanerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -150,6 +154,7 @@ impl Service for AdwCleanerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -335,6 +340,7 @@ impl Service for AdwCleanerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/battery_info.rs b/src-tauri/src/services/battery_info.rs deleted file mode 100644 index f0d855d..0000000 --- a/src-tauri/src/services/battery_info.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Battery Info Service -//! -//! Retrieves battery health and status information. - -use std::time::Instant; - -use battery::units::ratio::percent; -use battery::units::time::second; -use battery::Manager; -use chrono::Utc; -use serde_json::json; -use tauri::{AppHandle, Emitter}; - -use crate::services::Service; -use crate::types::{FindingSeverity, ServiceDefinition, ServiceFinding, ServiceResult}; - -// ============================================================================= -// Service Implementation -// ============================================================================= - -pub struct BatteryInfoService; - -impl Service for BatteryInfoService { - fn definition(&self) -> ServiceDefinition { - ServiceDefinition { - id: "battery-info".to_string(), - name: "Battery Health Check".to_string(), - description: "Analyzes battery health, capacity, and status".to_string(), - category: "diagnostics".to_string(), - estimated_duration_secs: 5, - required_programs: vec![], - options: vec![], - icon: "battery-full".to_string(), - } - } - - fn run(&self, _options: &serde_json::Value, app: &AppHandle) -> ServiceResult { - let start = Instant::now(); - let mut logs: Vec = Vec::new(); - let mut findings: Vec = Vec::new(); - let service_id = "battery-info"; - - // Emit log helper - let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { - logs.push(log.to_string()); - let _ = app.emit( - "service-log", - json!({ - "serviceId": service_id, - "log": log, - "timestamp": Utc::now().to_rfc3339() - }), - ); - }; - - emit_log("Checking for battery...", &mut logs, app); - - let manager = Manager::new(); - - match manager { - Ok(manager) => { - let batteries: Vec<_> = manager - .batteries() - .ok() - .map(|iter| iter.filter_map(|b| b.ok()).collect()) - .unwrap_or_default(); - - if batteries.is_empty() { - emit_log( - "No battery detected - this appears to be a desktop system", - &mut logs, - app, - ); - - findings.push(ServiceFinding { - severity: FindingSeverity::Info, - title: "No Battery Detected".to_string(), - description: "This system does not have a battery. This is normal for desktop computers.".to_string(), - recommendation: None, - data: Some(json!({ - "type": "no_battery", - })), - }); - } else { - for (index, battery) in batteries.iter().enumerate() { - let battery_num = index + 1; - emit_log( - &format!("Analyzing battery {}...", battery_num), - &mut logs, - app, - ); - - // Get battery metrics - let charge_percent = battery.state_of_charge().get::(); - let health_percent = battery.state_of_health().get::(); - let state = format!("{:?}", battery.state()); - let technology = format!("{:?}", battery.technology()); - let cycle_count = battery.cycle_count(); - - // Time estimates - let time_to_full = battery.time_to_full().map(|t| t.get::() as u64); - let time_to_empty = - battery.time_to_empty().map(|t| t.get::() as u64); - - // Vendor info - let vendor = battery.vendor().map(|s| s.to_string()); - let model = battery.model().map(|s| s.to_string()); - - emit_log( - &format!(" State of Charge: {:.1}%", charge_percent), - &mut logs, - app, - ); - emit_log( - &format!(" State of Health: {:.1}%", health_percent), - &mut logs, - app, - ); - emit_log(&format!(" State: {}", state), &mut logs, app); - emit_log(&format!(" Technology: {}", technology), &mut logs, app); - if let Some(cycles) = cycle_count { - emit_log(&format!(" Cycle Count: {}", cycles), &mut logs, app); - } - - // Determine health severity - let health_severity = if health_percent >= 80.0 { - FindingSeverity::Success - } else if health_percent >= 60.0 { - FindingSeverity::Warning - } else if health_percent >= 40.0 { - FindingSeverity::Error - } else { - FindingSeverity::Critical - }; - - // Health status label - let health_status = if health_percent >= 80.0 { - "Good" - } else if health_percent >= 60.0 { - "Fair" - } else if health_percent >= 40.0 { - "Poor" - } else { - "Replace Soon" - }; - - // Main battery finding with all data - let title = if batteries.len() > 1 { - format!( - "Battery {} - {}% Health", - battery_num, health_percent as u32 - ) - } else { - format!("Battery Health: {:.0}%", health_percent) - }; - - findings.push(ServiceFinding { - severity: health_severity, - title, - description: format!( - "Currently at {:.0}% charge. Battery health is {}.", - charge_percent, health_status - ), - recommendation: if health_percent < 60.0 { - Some( - "Battery capacity is degraded. Consider replacing the battery." - .to_string(), - ) - } else if health_percent < 80.0 { - Some( - "Battery showing some wear. Monitor for further degradation." - .to_string(), - ) - } else { - None - }, - data: Some(json!({ - "type": "battery_status", - "batteryIndex": index, - "chargePercent": charge_percent, - "healthPercent": health_percent, - "healthStatus": health_status, - "state": state, - "technology": technology, - "cycleCount": cycle_count, - "timeToFullSecs": time_to_full, - "timeToEmptySecs": time_to_empty, - "vendor": vendor, - "model": model, - })), - }); - - // Add cycle count finding if available - if let Some(cycles) = cycle_count { - let cycle_severity = if cycles < 300 { - FindingSeverity::Success - } else if cycles < 500 { - FindingSeverity::Info - } else if cycles < 800 { - FindingSeverity::Warning - } else { - FindingSeverity::Error - }; - - findings.push(ServiceFinding { - severity: cycle_severity, - title: format!("Cycle Count: {}", cycles), - description: format!( - "Battery has been through {} charge cycles. {}", - cycles, - if cycles < 300 { - "Low usage." - } else if cycles < 500 { - "Normal usage." - } else { - "High usage - monitor battery health." - } - ), - recommendation: None, - data: Some(json!({"type": "cycles", "value": cycles})), - }); - } - - // Add time estimate if charging/discharging - if let Some(secs) = time_to_full { - let hours = secs / 3600; - let mins = (secs % 3600) / 60; - findings.push(ServiceFinding { - severity: FindingSeverity::Info, - title: format!("Time to Full: {}h {}m", hours, mins), - description: "Estimated time until battery is fully charged" - .to_string(), - recommendation: None, - data: Some(json!({"type": "time_to_full", "seconds": secs})), - }); - } - - if let Some(secs) = time_to_empty { - let hours = secs / 3600; - let mins = (secs % 3600) / 60; - let severity = if hours >= 2 { - FindingSeverity::Success - } else if hours >= 1 { - FindingSeverity::Info - } else { - FindingSeverity::Warning - }; - - findings.push(ServiceFinding { - severity, - title: format!("Battery Time Remaining: {}h {}m", hours, mins), - description: "Estimated time until battery is depleted".to_string(), - recommendation: None, - data: Some(json!({"type": "time_to_empty", "seconds": secs})), - }); - } - } - } - - emit_log("Battery check complete", &mut logs, app); - } - Err(e) => { - emit_log( - &format!("Error accessing battery info: {}", e), - &mut logs, - app, - ); - - findings.push(ServiceFinding { - severity: FindingSeverity::Warning, - title: "Battery Information Unavailable".to_string(), - description: format!("Could not access battery information: {}", e), - recommendation: Some( - "This may be a desktop system or the battery driver is not responding." - .to_string(), - ), - data: Some(json!({"type": "error", "message": e.to_string()})), - }); - } - } - - ServiceResult { - service_id: service_id.to_string(), - success: true, - error: None, - duration_ms: start.elapsed().as_millis() as u64, - findings, - logs, - } - } -} diff --git a/src-tauri/src/services/battery_report.rs b/src-tauri/src/services/battery_report.rs new file mode 100644 index 0000000..28ab950 --- /dev/null +++ b/src-tauri/src/services/battery_report.rs @@ -0,0 +1,503 @@ +//! Battery Report Service +//! +//! Comprehensive battery health service that combines real-time battery status +//! (via the `battery` crate) with detailed capacity history and degradation +//! analysis from Windows `powercfg /batteryreport`. + +use std::process::Command; +use std::time::Instant; + +use battery::units::ratio::percent; +use battery::units::time::second; +use battery::Manager; +use chrono::Utc; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{FindingSeverity, ServiceDefinition, ServiceFinding, ServiceResult}; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct BatteryReportService; + +impl Service for BatteryReportService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "battery-report".to_string(), + name: "Battery Health Check".to_string(), + description: + "Battery health, charge status, capacity history, and degradation analysis" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 10, + required_programs: vec![], // Built-in Windows tool + battery crate + options: vec![], + icon: "battery-charging".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, _options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "battery-report"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + // โ”€โ”€ Real-time battery status via battery crate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + emit_log("Checking battery status...", &mut logs, app); + + let mut realtime_charge: Option = None; + let mut realtime_state: Option = None; + let mut realtime_technology: Option = None; + let mut realtime_time_to_full: Option = None; + let mut realtime_time_to_empty: Option = None; + + match Manager::new() { + Ok(manager) => { + let batteries: Vec<_> = manager + .batteries() + .ok() + .map(|iter| iter.filter_map(|b| b.ok()).collect()) + .unwrap_or_default(); + + if batteries.is_empty() { + emit_log("No battery detected via system API", &mut logs, app); + } else { + let bat = &batteries[0]; + let charge = bat.state_of_charge().get::(); + let state = format!("{:?}", bat.state()); + let tech = format!("{:?}", bat.technology()); + + emit_log(&format!(" Charge: {:.0}% | State: {} | Tech: {}", charge, state, tech), &mut logs, app); + + realtime_charge = Some(charge); + realtime_state = Some(state); + realtime_technology = Some(tech); + realtime_time_to_full = bat.time_to_full().map(|t| t.get::() as u64); + realtime_time_to_empty = bat.time_to_empty().map(|t| t.get::() as u64); + } + } + Err(e) => { + emit_log(&format!("Could not read real-time battery info: {}", e), &mut logs, app); + } + } + + // โ”€โ”€ Detailed report via powercfg /batteryreport โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + emit_log("Generating battery report...", &mut logs, app); + + let temp_dir = std::env::temp_dir(); + let output_path = temp_dir.join("rustservice_battery_report.html"); + let output_path_str = output_path.to_string_lossy().to_string(); + + let result = Command::new("powercfg") + .args(["/batteryreport", "/output", &output_path_str]) + .output(); + + let output = match result { + Ok(output) => output, + Err(e) => { + emit_log( + &format!("ERROR: Failed to run powercfg: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Battery Report Failed".to_string(), + description: format!("Could not execute powercfg: {}", e), + recommendation: Some( + "Ensure you are running with administrator privileges.".to_string(), + ), + data: Some(json!({"type": "battery_report", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(format!("powercfg execution failed: {}", e)), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + let exit_code = output.status.code().unwrap_or(-1); + let stderr_str = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout_str = String::from_utf8_lossy(&output.stdout).to_string(); + + emit_log( + &format!("powercfg exited with code: {}", exit_code), + &mut logs, + app, + ); + + // Check for no battery (powercfg says no battery AND the crate found none) + if (stdout_str.contains("no batteries") + || stderr_str.contains("no batteries") + || stdout_str.contains("Unable to perform operation")) + && realtime_charge.is_none() + { + emit_log("No battery detected on this system.", &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Info, + title: "No Battery Detected".to_string(), + description: "This system does not have a battery. Battery report is not available on desktop computers.".to_string(), + recommendation: None, + data: Some(json!({ + "type": "battery_report", + "noBattery": true, + })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + // Read and parse the HTML report + let html_content = match std::fs::read_to_string(&output_path) { + Ok(content) => content, + Err(e) => { + emit_log( + &format!("ERROR: Could not read report file: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Report Read Failed".to_string(), + description: format!("Could not read battery report: {}", e), + recommendation: Some("Try running as administrator.".to_string()), + data: Some(json!({"type": "battery_report", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some("Failed to read battery report".to_string()), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + emit_log("Parsing battery report...", &mut logs, app); + + let parsed = parse_battery_html(&html_content); + + // Calculate health percentage + let health_percent = + if parsed.design_capacity_mwh > 0 && parsed.full_charge_capacity_mwh > 0 { + (parsed.full_charge_capacity_mwh as f64 / parsed.design_capacity_mwh as f64 * 100.0) + .min(100.0) + } else { + 0.0 + }; + + emit_log( + &format!( + "Design capacity: {} mWh, Full charge: {} mWh, Health: {:.1}%", + parsed.design_capacity_mwh, parsed.full_charge_capacity_mwh, health_percent + ), + &mut logs, + app, + ); + + if let Some(cycles) = parsed.cycle_count { + emit_log(&format!("Cycle count: {}", cycles), &mut logs, app); + } + + // Determine battery health severity + let (severity, title) = if health_percent >= 80.0 { + ( + FindingSeverity::Success, + format!("Battery Health: {:.1}% โ€” Good", health_percent), + ) + } else if health_percent >= 50.0 { + ( + FindingSeverity::Warning, + format!("Battery Health: {:.1}% โ€” Degraded", health_percent), + ) + } else if health_percent > 0.0 { + ( + FindingSeverity::Error, + format!( + "Battery Health: {:.1}% โ€” Replace Recommended", + health_percent + ), + ) + } else { + ( + FindingSeverity::Info, + "Battery Health: Unable to determine".to_string(), + ) + }; + + let description = format!( + "Design capacity: {} mWh | Current full charge: {} mWh | Health: {:.1}%{}", + parsed.design_capacity_mwh, + parsed.full_charge_capacity_mwh, + health_percent, + parsed + .cycle_count + .map(|c| format!(" | Cycles: {}", c)) + .unwrap_or_default() + ); + + let recommendation = if health_percent < 50.0 && health_percent > 0.0 { + Some("Battery has significantly degraded. Consider replacing the battery.".to_string()) + } else if health_percent < 80.0 && health_percent > 0.0 { + Some("Battery is showing wear. Monitor capacity over time.".to_string()) + } else { + None + }; + + // Build capacity history data for the renderer chart + let capacity_history: Vec = parsed + .capacity_history + .iter() + .map(|entry| { + json!({ + "date": entry.date, + "fullChargeCapacity": entry.full_charge_capacity_mwh, + "designCapacity": entry.design_capacity_mwh, + }) + }) + .collect(); + + findings.push(ServiceFinding { + severity, + title, + description, + recommendation, + data: Some(json!({ + "type": "battery_report", + "designCapacityMwh": parsed.design_capacity_mwh, + "fullChargeCapacityMwh": parsed.full_charge_capacity_mwh, + "healthPercent": health_percent, + "cycleCount": parsed.cycle_count, + "capacityHistory": capacity_history, + "batteryName": parsed.battery_name, + "manufacturer": parsed.manufacturer, + "chemistry": parsed.chemistry, + "chargePercent": realtime_charge, + "state": realtime_state, + "technology": realtime_technology, + "timeToFullSecs": realtime_time_to_full, + "timeToEmptySecs": realtime_time_to_empty, + })), + }); + + // Clean up temp file + let _ = std::fs::remove_file(&output_path); + + emit_log("Battery report analysis complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// HTML Parser +// ============================================================================= + +struct CapacityHistoryEntry { + date: String, + full_charge_capacity_mwh: u64, + design_capacity_mwh: u64, +} + +struct ParsedBatteryReport { + design_capacity_mwh: u64, + full_charge_capacity_mwh: u64, + cycle_count: Option, + capacity_history: Vec, + battery_name: String, + manufacturer: String, + chemistry: String, +} + +fn parse_battery_html(html: &str) -> ParsedBatteryReport { + let mut report = ParsedBatteryReport { + design_capacity_mwh: 0, + full_charge_capacity_mwh: 0, + cycle_count: None, + capacity_history: Vec::new(), + battery_name: String::new(), + manufacturer: String::new(), + chemistry: String::new(), + }; + + // Normalize the HTML: join all lines into one string, then split by table rows. + // powercfg /batteryreport output wraps lines at ~80 columns, which can split + // fields like "FULL CHARGE CAPACITY" across two lines. Normalizing first avoids + // all line-boundary parsing issues. + let normalized = html.lines().map(|l| l.trim()).collect::>().join(" "); + + // --- Parse the "Installed batteries" table for battery metadata --- + // The table has rows like: + // DESIGN CAPACITY84,000 mWh + // Split by to get individual rows, then extract label-value pairs. + if let Some(battery_section_start) = normalized.find("Installed batteries") { + let section = &normalized[battery_section_start..]; + // Find the end of this table + let section_end = section.find("").unwrap_or(section.len()); + let battery_table = §ion[..section_end]; + + // Split into rows + for row in battery_table.split("") { + // Extract cells from each row + let cells: Vec = row + .split("") + .map(|c| strip_html_tags(c)) + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + + if cells.len() >= 2 { + let label = cells[0].to_uppercase(); + let value = &cells[1]; + + if label.contains("DESIGN CAPACITY") && report.design_capacity_mwh == 0 { + if let Some(val) = extract_mwh_from_text(value) { + report.design_capacity_mwh = val; + } + } else if (label.contains("FULL CHARGE CAPACITY") + || label.contains("LAST FULL CHARGE")) + && report.full_charge_capacity_mwh == 0 + { + if let Some(val) = extract_mwh_from_text(value) { + report.full_charge_capacity_mwh = val; + } + } else if label.contains("CYCLE COUNT") && report.cycle_count.is_none() { + let num_str: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(val) = num_str.parse::() { + report.cycle_count = Some(val); + } + } else if label == "NAME" && report.battery_name.is_empty() { + report.battery_name = value.to_string(); + } else if label.contains("MANUFACTURER") + && report.manufacturer.is_empty() + && !label.contains("SYSTEM") + { + report.manufacturer = value.to_string(); + } else if label.contains("CHEMISTRY") && report.chemistry.is_empty() { + report.chemistry = value.to_string(); + } + } + } + } + + // --- Parse "Battery capacity history" table --- + // Rows contain: date | full charge capacity | design capacity + if let Some(history_start) = normalized.find("Battery capacity history") { + let section = &normalized[history_start..]; + let section_end = section.find("").unwrap_or(section.len()); + let history_table = §ion[..section_end]; + + for row in history_table.split("") { + // Skip header rows + if row.contains(" = row + .split("") + .map(|c| strip_html_tags(c)) + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect(); + + // Need at least 3 cells: date, full charge, design + if cells.len() >= 3 { + // Find the date cell (contains a dash and is date-like) + let date = cells[0].trim().to_string(); + if date.len() >= 8 && date.contains('-') { + // The mWh values may be in cells[1] and cells[2], or possibly + // offset if there are extra columns. Find the two mWh values. + let mut mwh_values: Vec = Vec::new(); + for cell in &cells[1..] { + if let Some(val) = extract_mwh_from_text(cell) { + mwh_values.push(val); + } + } + if mwh_values.len() >= 2 { + report.capacity_history.push(CapacityHistoryEntry { + date, + full_charge_capacity_mwh: mwh_values[0], + design_capacity_mwh: mwh_values[1], + }); + } + } + } + } + } + + report +} + +fn extract_mwh_from_text(text: &str) -> Option { + // Look for patterns like "41,440 mWh" or "41440 mWh" + let cleaned: String = text.replace(",", "").replace(" ", ""); + if let Some(pos) = cleaned.to_lowercase().find("mwh") { + // Walk backwards from the "mwh" position to find the start of the number + let num_part = &cleaned[..pos]; + let num_str: String = num_part + .chars() + .rev() + .take_while(|c| c.is_ascii_digit()) + .collect::>() + .into_iter() + .rev() + .collect(); + if !num_str.is_empty() { + return num_str.parse::().ok(); + } + } + None +} + +fn strip_html_tags(s: &str) -> String { + let mut result = String::new(); + let mut in_tag = false; + for ch in s.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + result.trim().to_string() +} diff --git a/src-tauri/src/services/bleachbit.rs b/src-tauri/src/services/bleachbit.rs index ccd682f..37e2841 100644 --- a/src-tauri/src/services/bleachbit.rs +++ b/src-tauri/src/services/bleachbit.rs @@ -34,6 +34,8 @@ impl Service for BleachBitService { required_programs: vec!["bleachbit".to_string()], options: vec![], icon: "trash-2".to_string(), + exclusive_resources: vec!["disk-heavy".to_string()], + dependencies: vec![], } } @@ -93,6 +95,7 @@ impl Service for BleachBitService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -108,6 +111,7 @@ impl Service for BleachBitService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -163,6 +167,7 @@ impl Service for BleachBitService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -245,6 +250,7 @@ impl Service for BleachBitService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/chkdsk.rs b/src-tauri/src/services/chkdsk.rs index 9bb9758..c9cc03e 100644 --- a/src-tauri/src/services/chkdsk.rs +++ b/src-tauri/src/services/chkdsk.rs @@ -83,6 +83,8 @@ impl Service for ChkdskService { }, ], icon: "hard-drive-download".to_string(), + exclusive_resources: vec!["disk-exclusive".to_string()], + dependencies: vec![], } } @@ -147,6 +149,7 @@ impl Service for ChkdskService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } @@ -206,6 +209,7 @@ impl Service for ChkdskService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -351,6 +355,7 @@ impl Service for ChkdskService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/disk_space.rs b/src-tauri/src/services/disk_space.rs index 9225594..7fc9565 100644 --- a/src-tauri/src/services/disk_space.rs +++ b/src-tauri/src/services/disk_space.rs @@ -29,6 +29,8 @@ impl Service for DiskSpaceService { required_programs: vec![], options: vec![], icon: "hard-drive".to_string(), + exclusive_resources: vec![], + dependencies: vec![], } } @@ -180,6 +182,7 @@ impl Service for DiskSpaceService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/dism.rs b/src-tauri/src/services/dism.rs index dfcbbb9..106e767 100644 --- a/src-tauri/src/services/dism.rs +++ b/src-tauri/src/services/dism.rs @@ -43,6 +43,8 @@ impl Service for DismService { ), }], icon: "package-check".to_string(), + exclusive_resources: vec!["disk-heavy".to_string()], + dependencies: vec![], } } @@ -117,6 +119,7 @@ impl Service for DismService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -258,6 +261,7 @@ impl Service for DismService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/drivecleanup.rs b/src-tauri/src/services/drivecleanup.rs index 3529cda..3e1e91d 100644 --- a/src-tauri/src/services/drivecleanup.rs +++ b/src-tauri/src/services/drivecleanup.rs @@ -47,6 +47,8 @@ impl Service for DriveCleanupService { ), }], icon: "usb".to_string(), + exclusive_resources: vec!["disk-heavy".to_string()], + dependencies: vec![], } } @@ -106,6 +108,7 @@ impl Service for DriveCleanupService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -121,6 +124,7 @@ impl Service for DriveCleanupService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -175,6 +179,7 @@ impl Service for DriveCleanupService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -283,6 +288,7 @@ impl Service for DriveCleanupService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/driver_audit.rs b/src-tauri/src/services/driver_audit.rs new file mode 100644 index 0000000..3acfbc3 --- /dev/null +++ b/src-tauri/src/services/driver_audit.rs @@ -0,0 +1,337 @@ +//! Driver Audit Service +//! +//! Runs Windows `driverquery /v /fo csv` to inventory all installed drivers. +//! Flags stopped, degraded, or potentially problematic drivers. + +use std::process::Command; +use std::time::Instant; + +use chrono::Utc; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, ServiceResult, +}; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct DriverAuditService; + +impl Service for DriverAuditService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "driver-audit".to_string(), + name: "Driver Audit".to_string(), + description: "Inventory all installed drivers and flag stopped or problematic ones" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 15, + required_programs: vec![], // Built-in Windows tool + options: vec![ServiceOptionSchema { + id: "show_all".to_string(), + label: "Show All Drivers".to_string(), + option_type: "boolean".to_string(), + default_value: json!(false), + min: None, + max: None, + options: None, + description: Some("Show all drivers, not just problematic ones".to_string()), + }], + icon: "cpu".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "driver-audit"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + let show_all = options + .get("show_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + emit_log("Running driver inventory...", &mut logs, app); + + // Run driverquery with verbose CSV output + let output = match Command::new("driverquery") + .args(["/v", "/fo", "csv"]) + .output() + { + Ok(output) => output, + Err(e) => { + emit_log( + &format!("ERROR: Failed to run driverquery: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Driver Query Failed".to_string(), + description: format!("Could not execute driverquery: {}", e), + recommendation: Some("Try running as administrator.".to_string()), + data: Some(json!({"type": "driver_audit", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(format!("driverquery execution failed: {}", e)), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + // driverquery outputs in the system's encoding โ€” try UTF-8 first, then lossy + let stdout = String::from_utf8(output.stdout.clone()) + .unwrap_or_else(|_| String::from_utf8_lossy(&output.stdout).to_string()); + + let exit_code = output.status.code().unwrap_or(-1); + emit_log( + &format!("driverquery exited with code: {}", exit_code), + &mut logs, + app, + ); + + // Parse CSV output + let drivers = parse_driverquery_csv(&stdout); + emit_log( + &format!("Found {} installed drivers", drivers.len()), + &mut logs, + app, + ); + + // Categorize drivers + let total = drivers.len(); + let running: Vec<&DriverInfo> = drivers.iter().filter(|d| d.state == "Running").collect(); + let stopped: Vec<&DriverInfo> = drivers.iter().filter(|d| d.state == "Stopped").collect(); + let problem: Vec<&DriverInfo> = drivers + .iter() + .filter(|d| d.status != "OK" || d.state == "Degraded" || d.state == "Unknown") + .collect(); + + emit_log( + &format!( + "Running: {}, Stopped: {}, Problem: {}", + running.len(), + stopped.len(), + problem.len() + ), + &mut logs, + app, + ); + + // Determine overall severity + let (severity, title) = if !problem.is_empty() { + ( + FindingSeverity::Warning, + format!( + "{} problematic driver(s) found out of {}", + problem.len(), + total + ), + ) + } else { + ( + FindingSeverity::Success, + format!("All {} drivers are healthy", total), + ) + }; + + // Build driver data for renderer + let driver_data: Vec = if show_all { + drivers.iter().map(|d| driver_to_json(d)).collect() + } else { + // Only include problematic + stopped (useful) drivers + drivers + .iter() + .filter(|d| d.status != "OK" || d.state != "Running") + .map(|d| driver_to_json(d)) + .collect() + }; + + findings.push(ServiceFinding { + severity, + title, + description: format!( + "{} total drivers: {} running, {} stopped, {} with issues.", + total, + running.len(), + stopped.len(), + problem.len() + ), + recommendation: if !problem.is_empty() { + Some("Review problematic drivers and update or reinstall as needed.".to_string()) + } else { + None + }, + data: Some(json!({ + "type": "driver_audit", + "totalDrivers": total, + "runningDrivers": running.len(), + "stoppedDrivers": stopped.len(), + "problemDrivers": problem.len(), + "drivers": driver_data, + "showAll": show_all, + })), + }); + + // Individual findings for problem drivers + for driver in &problem { + findings.push(ServiceFinding { + severity: FindingSeverity::Warning, + title: format!("Driver Issue: {}", driver.display_name), + description: format!( + "Module: {} | State: {} | Status: {} | Type: {}", + driver.module_name, driver.state, driver.status, driver.driver_type + ), + recommendation: Some( + "Check Device Manager for this driver and update if available.".to_string(), + ), + data: None, + }); + } + + // Clean up temp file + emit_log("Driver audit complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// CSV Parser +// ============================================================================= + +struct DriverInfo { + module_name: String, + display_name: String, + driver_type: String, + start_mode: String, + state: String, + status: String, + link_date: String, + path: String, +} + +fn driver_to_json(d: &DriverInfo) -> serde_json::Value { + json!({ + "moduleName": d.module_name, + "displayName": d.display_name, + "driverType": d.driver_type, + "startMode": d.start_mode, + "state": d.state, + "status": d.status, + "linkDate": d.link_date, + "path": d.path, + }) +} + +fn parse_driverquery_csv(csv_text: &str) -> Vec { + let mut drivers = Vec::new(); + let lines: Vec<&str> = csv_text.lines().collect(); + + if lines.is_empty() { + return drivers; + } + + // Parse header to find column indices + let header = parse_csv_line(lines[0]); + let idx_module = find_column(&header, &["Module Name", "module name"]); + let idx_display = find_column(&header, &["Display Name", "display name"]); + let idx_type = find_column(&header, &["Driver Type", "driver type"]); + let idx_start = find_column(&header, &["Start Mode", "start mode"]); + let idx_state = find_column(&header, &["State", "state"]); + let idx_status = find_column(&header, &["Status", "status"]); + let idx_link_date = find_column(&header, &["Link Date", "link date"]); + let idx_path = find_column(&header, &["Path", "path"]); + + for line in &lines[1..] { + if line.trim().is_empty() { + continue; + } + let cols = parse_csv_line(line); + if cols.len() < 3 { + continue; + } + + let driver = DriverInfo { + module_name: get_col(&cols, idx_module), + display_name: get_col(&cols, idx_display), + driver_type: get_col(&cols, idx_type), + start_mode: get_col(&cols, idx_start), + state: get_col(&cols, idx_state), + status: get_col(&cols, idx_status), + link_date: get_col(&cols, idx_link_date), + path: get_col(&cols, idx_path), + }; + + drivers.push(driver); + } + + drivers +} + +fn find_column(header: &[String], names: &[&str]) -> Option { + for name in names { + if let Some(idx) = header.iter().position(|h| h.eq_ignore_ascii_case(name)) { + return Some(idx); + } + } + None +} + +fn get_col(cols: &[String], idx: Option) -> String { + idx.and_then(|i| cols.get(i)) + .map(|s| s.to_string()) + .unwrap_or_default() +} + +/// Simple CSV line parser that handles quoted fields +fn parse_csv_line(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for ch in line.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + fields.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(ch), + } + } + fields.push(current.trim().to_string()); + fields +} diff --git a/src-tauri/src/services/energy_report.rs b/src-tauri/src/services/energy_report.rs new file mode 100644 index 0000000..464ecb2 --- /dev/null +++ b/src-tauri/src/services/energy_report.rs @@ -0,0 +1,516 @@ +//! Energy Report Service +//! +//! Runs Windows `powercfg /energy` to generate a power efficiency diagnostics report. +//! Parses the HTML output to extract errors, warnings, and informational items. +//! Requires administrator privileges. + +use std::process::Command; +use std::time::Instant; + +use chrono::Utc; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, ServiceResult, +}; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct EnergyReportService; + +impl Service for EnergyReportService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "energy-report".to_string(), + name: "Energy Report".to_string(), + description: + "Analyze power efficiency and identify battery drain issues using powercfg" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 75, // duration + processing + required_programs: vec![], // Built-in Windows tool + options: vec![ServiceOptionSchema { + id: "duration".to_string(), + label: "Trace Duration (seconds)".to_string(), + option_type: "number".to_string(), + default_value: json!(10), + min: Some(5.0), + max: Some(60.0), + options: None, + description: Some( + "How long to trace system activity before generating the report".to_string(), + ), + }], + icon: "zap".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "energy-report"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + let duration = options + .get("duration") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as u32; + + // Use temp directory for output + let temp_dir = std::env::temp_dir(); + let output_path = temp_dir.join("rustservice_energy_report.html"); + let output_path_str = output_path.to_string_lossy().to_string(); + + emit_log( + &format!("Starting energy efficiency trace ({} seconds)...", duration), + &mut logs, + app, + ); + emit_log( + "Please avoid using the computer during the trace for best results.", + &mut logs, + app, + ); + + // Run powercfg /energy + let result = Command::new("powercfg") + .args([ + "/energy", + "/output", + &output_path_str, + "/duration", + &duration.to_string(), + ]) + .output(); + + let output = match result { + Ok(output) => output, + Err(e) => { + emit_log( + &format!("ERROR: Failed to run powercfg: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Energy Report Failed".to_string(), + description: format!("Could not execute powercfg: {}", e), + recommendation: Some( + "Ensure you are running with administrator privileges.".to_string(), + ), + data: Some(json!({"type": "energy_report", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(format!("powercfg execution failed: {}", e)), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + let exit_code = output.status.code().unwrap_or(-1); + let stderr_str = String::from_utf8_lossy(&output.stderr).to_string(); + + emit_log( + &format!("powercfg exited with code: {}", exit_code), + &mut logs, + app, + ); + + // Read and parse the HTML report + let html_content = match std::fs::read_to_string(&output_path) { + Ok(content) => content, + Err(e) => { + // If we can't read the output, check if access was denied + let err_msg = if stderr_str.contains("requires elevated permissions") + || stderr_str.contains("Access is denied") + || exit_code != 0 + { + "Administrator privileges are required to generate an energy report." + .to_string() + } else { + format!("Could not read energy report: {}", e) + }; + + emit_log(&format!("ERROR: {}", err_msg), &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Report Generation Failed".to_string(), + description: err_msg, + recommendation: Some( + "Run this application as administrator and try again.".to_string(), + ), + data: Some(json!({"type": "energy_report", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some("Failed to generate energy report".to_string()), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + emit_log("Parsing energy report...", &mut logs, app); + + // Parse the HTML for errors, warnings, and informational items + let parsed = parse_energy_html(&html_content); + + emit_log( + &format!( + "Found {} errors, {} warnings, {} informational items", + parsed.errors.len(), + parsed.warnings.len(), + parsed.informational.len() + ), + &mut logs, + app, + ); + + // Determine overall severity + let overall_severity = if !parsed.errors.is_empty() { + FindingSeverity::Warning + } else if !parsed.warnings.is_empty() { + FindingSeverity::Info + } else { + FindingSeverity::Success + }; + + let overall_title = if !parsed.errors.is_empty() { + format!( + "Power Issues Found: {} error(s), {} warning(s)", + parsed.errors.len(), + parsed.warnings.len() + ) + } else if !parsed.warnings.is_empty() { + format!("{} Warning(s) Found", parsed.warnings.len()) + } else { + "No Power Issues Detected".to_string() + }; + + let overall_description = format!( + "Energy efficiency analysis complete. {} error(s), {} warning(s), {} informational item(s) found during the {}-second trace.", + parsed.errors.len(), + parsed.warnings.len(), + parsed.informational.len(), + duration + ); + + // Build structured data for renderer + let items: Vec = parsed + .errors + .iter() + .map(|item| { + json!({ + "severity": "error", + "category": item.category, + "title": item.title, + "description": item.description, + }) + }) + .chain(parsed.warnings.iter().map(|item| { + json!({ + "severity": "warning", + "category": item.category, + "title": item.title, + "description": item.description, + }) + })) + .chain(parsed.informational.iter().map(|item| { + json!({ + "severity": "info", + "category": item.category, + "title": item.title, + "description": item.description, + }) + })) + .collect(); + + findings.push(ServiceFinding { + severity: overall_severity, + title: overall_title, + description: overall_description, + recommendation: if !parsed.errors.is_empty() { + Some("Review the power errors and adjust power settings accordingly.".to_string()) + } else { + None + }, + data: Some(json!({ + "type": "energy_report", + "errorCount": parsed.errors.len(), + "warningCount": parsed.warnings.len(), + "infoCount": parsed.informational.len(), + "items": items, + "duration": duration, + })), + }); + + // Add individual findings for errors + for item in &parsed.errors { + findings.push(ServiceFinding { + severity: FindingSeverity::Warning, + title: item.title.clone(), + description: item.description.clone(), + recommendation: Some("Check power settings and running applications.".to_string()), + data: None, + }); + } + + // Clean up temp file + let _ = std::fs::remove_file(&output_path); + + emit_log("Energy report analysis complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// HTML Parser +// ============================================================================= + +struct EnergyItem { + category: String, + title: String, + description: String, +} + +struct ParsedReport { + errors: Vec, + warnings: Vec, + informational: Vec, +} + +fn parse_energy_html(html: &str) -> ParsedReport { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + let mut informational = Vec::new(); + + // The powercfg /energy HTML has sections with specific CSS classes + // Errors: + // Warnings: + // Info: + // Each section has a title in and details in subsequent rows + + let mut current_section: Option<&str> = None; + let mut current_category = String::new(); + let mut current_title = String::new(); + let mut current_desc_lines: Vec = Vec::new(); + let mut in_item = false; + + for line in html.lines() { + let trimmed = line.trim(); + + // Detect section headers + if trimmed.contains("class=\"pointed_out_err\"") + || trimmed.contains("class='pointed_out_err'") + { + // Save previous item + if in_item && !current_title.is_empty() { + save_item( + current_section, + ¤t_category, + ¤t_title, + ¤t_desc_lines, + &mut errors, + &mut warnings, + &mut informational, + ); + } + current_section = Some("error"); + current_title.clear(); + current_desc_lines.clear(); + in_item = true; + } else if trimmed.contains("class=\"pointed_out_wrn\"") + || trimmed.contains("class='pointed_out_wrn'") + { + if in_item && !current_title.is_empty() { + save_item( + current_section, + ¤t_category, + ¤t_title, + ¤t_desc_lines, + &mut errors, + &mut warnings, + &mut informational, + ); + } + current_section = Some("warning"); + current_title.clear(); + current_desc_lines.clear(); + in_item = true; + } else if trimmed.contains("class=\"pointed_out_inf\"") + || trimmed.contains("class='pointed_out_inf'") + { + if in_item && !current_title.is_empty() { + save_item( + current_section, + ¤t_category, + ¤t_title, + ¤t_desc_lines, + &mut errors, + &mut warnings, + &mut informational, + ); + } + current_section = Some("info"); + current_title.clear(); + current_desc_lines.clear(); + in_item = true; + } + + // Extract category from section headings like "Power Policy:..." + if trimmed.contains("pointed_out_title") { + let text = strip_html_tags(trimmed); + if !text.is_empty() { + current_category = text; + } + } + + // Extract title and description from table cells + if in_item { + if trimmed.starts_with("") && current_title.is_empty() { + let text = strip_html_tags(trimmed); + if !text.is_empty() { + current_title = text; + } + } else if trimmed.starts_with("") && !current_title.is_empty() { + let text = strip_html_tags(trimmed); + if !text.is_empty() { + current_desc_lines.push(text); + } + } + } + } + + // Save last item + if in_item && !current_title.is_empty() { + save_item( + current_section, + ¤t_category, + ¤t_title, + ¤t_desc_lines, + &mut errors, + &mut warnings, + &mut informational, + ); + } + + // Fallback: if no structured data found, try simpler parsing + if errors.is_empty() && warnings.is_empty() && informational.is_empty() { + parse_energy_fallback(html, &mut errors, &mut warnings, &mut informational); + } + + ParsedReport { + errors, + warnings, + informational, + } +} + +fn save_item( + section: Option<&str>, + category: &str, + title: &str, + desc_lines: &[String], + errors: &mut Vec, + warnings: &mut Vec, + informational: &mut Vec, +) { + let item = EnergyItem { + category: category.to_string(), + title: title.to_string(), + description: desc_lines.join("; "), + }; + match section { + Some("error") => errors.push(item), + Some("warning") => warnings.push(item), + Some("info") => informational.push(item), + _ => {} + } +} + +/// Fallback parser that counts section markers +fn parse_energy_fallback( + html: &str, + errors: &mut Vec, + warnings: &mut Vec, + informational: &mut Vec, +) { + // Count occurrences of markers + let error_count = html.matches("pointed_out_err").count(); + let warning_count = html.matches("pointed_out_wrn").count(); + let info_count = html.matches("pointed_out_inf").count(); + + if error_count > 0 { + errors.push(EnergyItem { + category: "Power Policy".to_string(), + title: format!("{} power error(s) detected", error_count), + description: "The energy report found power configuration errors. Review the full report for details.".to_string(), + }); + } + if warning_count > 0 { + warnings.push(EnergyItem { + category: "Power Policy".to_string(), + title: format!("{} power warning(s) detected", warning_count), + description: + "The energy report found power warnings. Review the full report for details." + .to_string(), + }); + } + if info_count > 0 { + informational.push(EnergyItem { + category: "General".to_string(), + title: format!("{} informational item(s)", info_count), + description: "Additional power information is available in the full report." + .to_string(), + }); + } +} + +fn strip_html_tags(s: &str) -> String { + let mut result = String::new(); + let mut in_tag = false; + for ch in s.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + result.trim().to_string() +} diff --git a/src-tauri/src/services/furmark.rs b/src-tauri/src/services/furmark.rs index d6cec37..f8e5e8b 100644 --- a/src-tauri/src/services/furmark.rs +++ b/src-tauri/src/services/furmark.rs @@ -66,6 +66,8 @@ impl Service for FurmarkService { }, ], icon: "flame".to_string(), + exclusive_resources: vec!["cpu-stress".to_string()], + dependencies: vec![], } } @@ -112,6 +114,7 @@ impl Service for FurmarkService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -127,6 +130,7 @@ impl Service for FurmarkService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -203,6 +207,7 @@ impl Service for FurmarkService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -240,6 +245,7 @@ impl Service for FurmarkService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } @@ -350,6 +356,7 @@ impl Service for FurmarkService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/heavyload.rs b/src-tauri/src/services/heavyload.rs index 9a59b24..fce9380 100644 --- a/src-tauri/src/services/heavyload.rs +++ b/src-tauri/src/services/heavyload.rs @@ -74,6 +74,8 @@ impl Service for HeavyLoadService { }, ], icon: "weight".to_string(), + exclusive_resources: vec!["cpu-stress".to_string()], + dependencies: vec![], } } @@ -138,6 +140,7 @@ impl Service for HeavyLoadService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } @@ -163,6 +166,7 @@ impl Service for HeavyLoadService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -178,6 +182,7 @@ impl Service for HeavyLoadService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -255,6 +260,7 @@ impl Service for HeavyLoadService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -329,6 +335,7 @@ impl Service for HeavyLoadService { duration_ms: actual_duration.as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/installed_software.rs b/src-tauri/src/services/installed_software.rs new file mode 100644 index 0000000..d35bcc8 --- /dev/null +++ b/src-tauri/src/services/installed_software.rs @@ -0,0 +1,334 @@ +//! Installed Software Audit Service +//! +//! Queries the Windows Registry for all installed programs. +//! Reports software name, version, publisher, install date, and size. + +use std::process::Command; +use std::time::Instant; + +use chrono::Utc; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, ServiceResult, +}; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct InstalledSoftwareService; + +impl Service for InstalledSoftwareService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "installed-software".to_string(), + name: "Installed Software Audit".to_string(), + description: + "Full inventory of installed programs with sizes, versions, and install dates" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 10, + required_programs: vec![], // Uses PowerShell + Registry + options: vec![ServiceOptionSchema { + id: "include_updates".to_string(), + label: "Include Windows Updates".to_string(), + option_type: "boolean".to_string(), + default_value: json!(false), + min: None, + max: None, + options: None, + description: Some("Include Windows updates and hotfixes in the list".to_string()), + }], + icon: "package-search".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "installed-software"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + let include_updates = options + .get("include_updates") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + emit_log( + "Querying installed software from registry...", + &mut logs, + app, + ); + + // PowerShell command to read both HKLM and HKCU uninstall keys + let ps_script = r#" +$paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$apps = foreach ($path in $paths) { + Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, EstimatedSize, SystemComponent, ReleaseType, ParentKeyName +} +$apps | ConvertTo-Json -Depth 3 +"#; + + let output = match Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + ps_script, + ]) + .output() + { + Ok(output) => output, + Err(e) => { + emit_log( + &format!("ERROR: Failed to run PowerShell: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Software Audit Failed".to_string(), + description: format!("Could not query registry: {}", e), + recommendation: Some("Ensure PowerShell is available.".to_string()), + data: Some(json!({"type": "installed_software", "error": true})), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(format!("PowerShell execution failed: {}", e)), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + emit_log("Parsing software inventory...", &mut logs, app); + + // Parse JSON output + let raw_apps: Vec = match serde_json::from_str(&stdout) { + Ok(serde_json::Value::Array(arr)) => arr, + Ok(single) => vec![single], // Single result returns as object, not array + Err(e) => { + emit_log( + &format!("Warning: JSON parse error, trying line-by-line: {}", e), + &mut logs, + app, + ); + Vec::new() + } + }; + + // Process and filter apps + let mut programs: Vec = Vec::new(); + let mut seen_names = std::collections::HashSet::new(); + + for app_val in &raw_apps { + let name = app_val + .get("DisplayName") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if name.is_empty() { + continue; + } + + // Skip system components unless they want updates + let is_system = app_val + .get("SystemComponent") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + == 1; + let release_type = app_val + .get("ReleaseType") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let is_update = release_type == "Update" + || release_type == "Security Update" + || release_type == "Hotfix" + || name.starts_with("KB") + || app_val.get("ParentKeyName").is_some(); + + if !include_updates && (is_system || is_update) { + continue; + } + + // Deduplicate + if !seen_names.insert(name.clone()) { + continue; + } + + let version = app_val + .get("DisplayVersion") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let publisher = app_val + .get("Publisher") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let install_date = app_val + .get("InstallDate") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let size_kb = app_val + .get("EstimatedSize") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + programs.push(ProgramInfo { + name, + version, + publisher, + install_date, + size_kb, + }); + } + + // Sort by size (largest first) + programs.sort_by(|a, b| b.size_kb.cmp(&a.size_kb)); + + let total_size_mb: f64 = programs.iter().map(|p| p.size_kb as f64).sum::() / 1024.0; + let total_count = programs.len(); + + emit_log( + &format!( + "Found {} programs, total estimated size: {:.1} MB", + total_count, total_size_mb + ), + &mut logs, + app, + ); + + // Build program data for renderer + let program_data: Vec = programs + .iter() + .map(|p| { + json!({ + "name": p.name, + "version": p.version, + "publisher": p.publisher, + "installDate": p.install_date, + "sizeMb": p.size_kb as f64 / 1024.0, + }) + }) + .collect(); + + // Top 10 by size + let top_by_size: Vec = programs + .iter() + .take(10) + .filter(|p| p.size_kb > 0) + .map(|p| { + json!({ + "name": p.name, + "sizeMb": p.size_kb as f64 / 1024.0, + }) + }) + .collect(); + + // Recently installed (parse date YYYYMMDD format) + let mut recent: Vec<&ProgramInfo> = programs + .iter() + .filter(|p| !p.install_date.is_empty() && p.install_date.len() >= 8) + .collect(); + recent.sort_by(|a, b| b.install_date.cmp(&a.install_date)); + let recent_data: Vec = recent + .iter() + .take(10) + .map(|p| { + json!({ + "name": p.name, + "version": p.version, + "installDate": format_install_date(&p.install_date), + }) + }) + .collect(); + + findings.push(ServiceFinding { + severity: FindingSeverity::Info, + title: format!("{} Programs Installed", total_count), + description: format!( + "{} programs found. Total estimated disk usage: {:.1} MB. Largest: {}", + total_count, + total_size_mb, + programs + .first() + .map(|p| format!("{} ({:.0} MB)", p.name, p.size_kb as f64 / 1024.0)) + .unwrap_or_else(|| "N/A".to_string()) + ), + recommendation: None, + data: Some(json!({ + "type": "installed_software", + "totalPrograms": total_count, + "totalSizeMb": total_size_mb, + "programs": program_data, + "topBySize": top_by_size, + "recentlyInstalled": recent_data, + "includeUpdates": include_updates, + })), + }); + + emit_log("Software audit complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// Types & Helpers +// ============================================================================= + +struct ProgramInfo { + name: String, + version: String, + publisher: String, + install_date: String, // YYYYMMDD format from registry + size_kb: u64, +} + +/// Format YYYYMMDD to YYYY-MM-DD +fn format_install_date(date: &str) -> String { + if date.len() >= 8 { + format!("{}-{}-{}", &date[0..4], &date[4..6], &date[6..8]) + } else { + date.to_string() + } +} diff --git a/src-tauri/src/services/iperf.rs b/src-tauri/src/services/iperf.rs index 4e33250..8569011 100644 --- a/src-tauri/src/services/iperf.rs +++ b/src-tauri/src/services/iperf.rs @@ -68,6 +68,8 @@ impl Service for IperfService { }, ], icon: "network".to_string(), + exclusive_resources: vec!["network-bandwidth".to_string()], + dependencies: vec![], } } @@ -126,6 +128,7 @@ impl Service for IperfService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } Err(e) => { @@ -136,6 +139,7 @@ impl Service for IperfService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } }; @@ -307,6 +311,7 @@ impl Service for IperfService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/kvrt_scan.rs b/src-tauri/src/services/kvrt_scan.rs index ea9d7d1..af7d6e9 100644 --- a/src-tauri/src/services/kvrt_scan.rs +++ b/src-tauri/src/services/kvrt_scan.rs @@ -34,6 +34,8 @@ impl Service for KvrtScanService { required_programs: vec!["kvrt".to_string()], options: vec![], icon: "shield-alert".to_string(), + exclusive_resources: vec!["filesystem-scan".to_string()], + dependencies: vec![], } } @@ -81,6 +83,7 @@ impl Service for KvrtScanService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -96,6 +99,7 @@ impl Service for KvrtScanService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -152,6 +156,7 @@ impl Service for KvrtScanService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -265,6 +270,7 @@ impl Service for KvrtScanService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 2bab3b9..58b6f3c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -4,21 +4,28 @@ //! Services implement the `Service` trait for consistent execution. mod adwcleaner; -mod battery_info; +mod battery_report; mod bleachbit; mod chkdsk; mod disk_space; mod dism; mod drivecleanup; +mod driver_audit; +mod energy_report; mod furmark; mod heavyload; +mod installed_software; mod iperf; mod kvrt_scan; +mod network_config; mod ping_test; +mod restore_point; mod sfc; mod smartctl; mod speedtest; +mod startup_optimize; mod stinger; +mod usb_stability; mod whynotwin11; mod windows_update; mod winsat; @@ -49,10 +56,10 @@ pub trait Service: Send + Sync { /// Static registry of all available services static SERVICE_REGISTRY: LazyLock>> = LazyLock::new(|| { let services: Vec> = vec![ + Box::new(restore_point::RestorePointService), Box::new(ping_test::PingTestService), Box::new(disk_space::DiskSpaceService), Box::new(winsat::WinsatService), - Box::new(battery_info::BatteryInfoService), Box::new(kvrt_scan::KvrtScanService), Box::new(adwcleaner::AdwCleanerService), Box::new(stinger::StingerService), @@ -68,6 +75,13 @@ static SERVICE_REGISTRY: LazyLock>> = LazyLock: Box::new(chkdsk::ChkdskService), Box::new(furmark::FurmarkService), Box::new(windows_update::WindowsUpdateService), + Box::new(energy_report::EnergyReportService), + Box::new(battery_report::BatteryReportService), + Box::new(driver_audit::DriverAuditService), + Box::new(installed_software::InstalledSoftwareService), + Box::new(network_config::NetworkConfigService), + Box::new(usb_stability::UsbStabilityService), + Box::new(startup_optimize::StartupOptimizeService), ]; services @@ -128,7 +142,7 @@ pub fn get_all_presets() -> Vec { options: serde_json::json!({}), }, PresetServiceConfig { - service_id: "battery-info".to_string(), + service_id: "battery-report".to_string(), enabled: true, options: serde_json::json!({}), }, @@ -137,6 +151,16 @@ pub fn get_all_presets() -> Vec { enabled: true, options: serde_json::json!({}), }, + PresetServiceConfig { + service_id: "energy-report".to_string(), + enabled: true, + options: serde_json::json!({"duration": 10}), + }, + PresetServiceConfig { + service_id: "network-config".to_string(), + enabled: true, + options: serde_json::json!({}), + }, ], icon: "stethoscope".to_string(), color: "blue".to_string(), @@ -146,6 +170,11 @@ pub fn get_all_presets() -> Vec { name: "General Service".to_string(), description: "Standard maintenance tasks for regular checkups".to_string(), services: vec![ + PresetServiceConfig { + service_id: "restore-point".to_string(), + enabled: true, + options: serde_json::json!({}), + }, PresetServiceConfig { service_id: "adwcleaner".to_string(), enabled: true, @@ -177,7 +206,12 @@ pub fn get_all_presets() -> Vec { options: serde_json::json!({}), }, PresetServiceConfig { - service_id: "battery-info".to_string(), + service_id: "battery-report".to_string(), + enabled: true, + options: serde_json::json!({}), + }, + PresetServiceConfig { + service_id: "network-config".to_string(), enabled: true, options: serde_json::json!({}), }, @@ -190,6 +224,11 @@ pub fn get_all_presets() -> Vec { name: "Complete Service".to_string(), description: "Comprehensive scan and cleanup for thorough maintenance".to_string(), services: vec![ + PresetServiceConfig { + service_id: "restore-point".to_string(), + enabled: true, + options: serde_json::json!({}), + }, PresetServiceConfig { service_id: "adwcleaner".to_string(), enabled: true, @@ -236,7 +275,7 @@ pub fn get_all_presets() -> Vec { options: serde_json::json!({}), }, PresetServiceConfig { - service_id: "battery-info".to_string(), + service_id: "battery-report".to_string(), enabled: true, options: serde_json::json!({}), }, @@ -265,6 +304,36 @@ pub fn get_all_presets() -> Vec { enabled: true, options: serde_json::json!({"install_updates": true, "include_drivers": true}), }, + PresetServiceConfig { + service_id: "energy-report".to_string(), + enabled: true, + options: serde_json::json!({"duration": 10}), + }, + PresetServiceConfig { + service_id: "driver-audit".to_string(), + enabled: true, + options: serde_json::json!({"show_all": false}), + }, + PresetServiceConfig { + service_id: "installed-software".to_string(), + enabled: true, + options: serde_json::json!({"include_updates": false}), + }, + PresetServiceConfig { + service_id: "network-config".to_string(), + enabled: true, + options: serde_json::json!({}), + }, + PresetServiceConfig { + service_id: "usb-stability".to_string(), + enabled: false, + options: serde_json::json!({"test_intensity": "standard", "verify_integrity": true}), + }, + PresetServiceConfig { + service_id: "startup-optimize".to_string(), + enabled: true, + options: serde_json::json!({"disable_unnecessary": false, "include_disabled": false}), + }, ], icon: "shield-check".to_string(), color: "purple".to_string(), @@ -274,6 +343,11 @@ pub fn get_all_presets() -> Vec { name: "Custom Service".to_string(), description: "Build your own service configuration".to_string(), services: vec![ + PresetServiceConfig { + service_id: "restore-point".to_string(), + enabled: false, + options: serde_json::json!({}), + }, PresetServiceConfig { service_id: "adwcleaner".to_string(), enabled: false, @@ -320,7 +394,7 @@ pub fn get_all_presets() -> Vec { options: serde_json::json!({}), }, PresetServiceConfig { - service_id: "battery-info".to_string(), + service_id: "battery-report".to_string(), enabled: false, options: serde_json::json!({}), }, @@ -369,6 +443,36 @@ pub fn get_all_presets() -> Vec { enabled: false, options: serde_json::json!({"action": "report", "include_pups": false}), }, + PresetServiceConfig { + service_id: "energy-report".to_string(), + enabled: false, + options: serde_json::json!({"duration": 10}), + }, + PresetServiceConfig { + service_id: "driver-audit".to_string(), + enabled: false, + options: serde_json::json!({"show_all": false}), + }, + PresetServiceConfig { + service_id: "installed-software".to_string(), + enabled: false, + options: serde_json::json!({"include_updates": false}), + }, + PresetServiceConfig { + service_id: "network-config".to_string(), + enabled: false, + options: serde_json::json!({"include_disabled": false}), + }, + PresetServiceConfig { + service_id: "usb-stability".to_string(), + enabled: false, + options: serde_json::json!({"test_intensity": "standard", "verify_integrity": true}), + }, + PresetServiceConfig { + service_id: "startup-optimize".to_string(), + enabled: false, + options: serde_json::json!({"disable_unnecessary": false, "include_disabled": false}), + }, ], icon: "settings-2".to_string(), color: "orange".to_string(), diff --git a/src-tauri/src/services/network_config.rs b/src-tauri/src/services/network_config.rs new file mode 100644 index 0000000..0c48e8e --- /dev/null +++ b/src-tauri/src/services/network_config.rs @@ -0,0 +1,506 @@ +//! Network Configuration Service +//! +//! Runs `ipconfig /all` and `netsh interface show interface` to gather +//! detailed network adapter configuration, DNS, DHCP, and gateway info. + +use std::process::Command; +use std::time::Instant; + +use chrono::Utc; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, ServiceResult, +}; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct NetworkConfigService; + +impl Service for NetworkConfigService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "network-config".to_string(), + name: "Network Configuration".to_string(), + description: + "Analyze network adapters, DNS settings, DHCP configuration, and connectivity" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 8, + required_programs: vec![], // Built-in Windows tools + options: vec![ServiceOptionSchema { + id: "include_disabled".to_string(), + label: "Include Disabled Adapters".to_string(), + option_type: "boolean".to_string(), + default_value: json!(false), + min: None, + max: None, + options: None, + description: Some("Show disabled and disconnected network adapters".to_string()), + }], + icon: "globe".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "network-config"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + let include_disabled = options + .get("include_disabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + emit_log("Running network configuration analysis...", &mut logs, app); + + // Run ipconfig /all + emit_log( + "Gathering adapter details (ipconfig /all)...", + &mut logs, + app, + ); + let ipconfig_output = match Command::new("ipconfig").arg("/all").output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + emit_log( + &format!( + "ipconfig exited with code: {}", + output.status.code().unwrap_or(-1) + ), + &mut logs, + app, + ); + stdout + } + Err(e) => { + emit_log( + &format!("ERROR: Failed to run ipconfig: {}", e), + &mut logs, + app, + ); + String::new() + } + }; + + // Run netsh to get adapter status + emit_log( + "Checking adapter status (netsh interface show interface)...", + &mut logs, + app, + ); + let netsh_output = match Command::new("netsh") + .args(["interface", "show", "interface"]) + .output() + { + Ok(output) => String::from_utf8_lossy(&output.stdout).to_string(), + Err(e) => { + emit_log(&format!("Warning: netsh failed: {}", e), &mut logs, app); + String::new() + } + }; + + // Also get DNS cache stats + emit_log("Checking DNS configuration...", &mut logs, app); + + // Parse adapters from ipconfig + let mut adapters = parse_ipconfig(&ipconfig_output); + + // Enrich with netsh status + let netsh_statuses = parse_netsh_interfaces(&netsh_output); + for adapter in &mut adapters { + if let Some(status) = netsh_statuses.get(&adapter.name) { + adapter.admin_state = status.admin_state.clone(); + adapter.interface_type = status.interface_type.clone(); + } + } + + // Filter disabled if not requested + if !include_disabled { + adapters + .retain(|a| a.admin_state != "Disabled" && !a.media_state.contains("disconnected")); + } + + let total_adapters = adapters.len(); + let connected: Vec<&AdapterInfo> = adapters + .iter() + .filter(|a| !a.ipv4_address.is_empty()) + .collect(); + let has_ipv6: Vec<&AdapterInfo> = adapters + .iter() + .filter(|a| !a.ipv6_address.is_empty()) + .collect(); + + emit_log( + &format!( + "Found {} adapters, {} connected, {} with IPv6", + total_adapters, + connected.len(), + has_ipv6.len() + ), + &mut logs, + app, + ); + + // Analyze DNS servers + let mut dns_analysis = Vec::new(); + let known_public_dns = [ + ("8.8.8.8", "Google DNS"), + ("8.8.4.4", "Google DNS"), + ("1.1.1.1", "Cloudflare DNS"), + ("1.0.0.1", "Cloudflare DNS"), + ("9.9.9.9", "Quad9 DNS"), + ("208.67.222.222", "OpenDNS"), + ("208.67.220.220", "OpenDNS"), + ]; + + for adapter in &connected { + for dns in &adapter.dns_servers { + let provider = known_public_dns + .iter() + .find(|(ip, _)| ip == dns) + .map(|(_, name)| name.to_string()) + .unwrap_or_else(|| { + if dns.starts_with("192.168.") + || dns.starts_with("10.") + || dns.starts_with("172.") + { + "Private/Router DNS".to_string() + } else { + "ISP/Unknown DNS".to_string() + } + }); + dns_analysis.push(json!({ + "server": dns, + "provider": provider, + "adapter": adapter.name, + })); + } + } + + // Build adapter data for renderer + let adapter_data: Vec = adapters + .iter() + .map(|a| { + json!({ + "name": a.name, + "description": a.description, + "type": a.interface_type, + "status": if a.media_state.contains("disconnected") { "Disconnected" } else if a.ipv4_address.is_empty() { "No IP" } else { "Connected" }, + "ipv4": a.ipv4_address, + "ipv6": a.ipv6_address, + "subnetMask": a.subnet_mask, + "defaultGateway": a.default_gateway, + "dnsServers": a.dns_servers, + "dhcpEnabled": a.dhcp_enabled, + "dhcpServer": a.dhcp_server, + "macAddress": a.mac_address, + "adminState": a.admin_state, + }) + }) + .collect(); + + // Determine overall severity + let severity = if connected.is_empty() { + FindingSeverity::Error + } else { + FindingSeverity::Success + }; + + let title = if connected.is_empty() { + "No Connected Network Adapters".to_string() + } else { + format!("{} adapter(s) connected", connected.len()) + }; + + let description = format!( + "{} total adapter(s), {} connected. {}", + total_adapters, + connected.len(), + if !dns_analysis.is_empty() { + format!( + "DNS: {}", + dns_analysis + .iter() + .filter_map(|d| d.get("provider").and_then(|p| p.as_str())) + .collect::>() + .join(", ") + ) + } else { + "No DNS configured.".to_string() + } + ); + + findings.push(ServiceFinding { + severity, + title, + description, + recommendation: if connected.is_empty() { + Some("Check physical network connections and adapter settings.".to_string()) + } else { + None + }, + data: Some(json!({ + "type": "network_config", + "totalAdapters": total_adapters, + "connectedAdapters": connected.len(), + "ipv6Adapters": has_ipv6.len(), + "adapters": adapter_data, + "dnsAnalysis": dns_analysis, + "includeDisabled": include_disabled, + })), + }); + + // Flag interesting DNS configurations + let uses_isp_dns = dns_analysis.iter().any(|d| { + d.get("provider") + .and_then(|p| p.as_str()) + .map(|p| p.contains("ISP") || p.contains("Unknown")) + .unwrap_or(false) + }); + + if uses_isp_dns && !connected.is_empty() { + findings.push(ServiceFinding { + severity: FindingSeverity::Info, + title: "Using ISP DNS Servers".to_string(), + description: + "One or more adapters are using ISP-provided DNS servers. Public DNS (Cloudflare 1.1.1.1 or Google 8.8.8.8) may offer better performance and privacy." + .to_string(), + recommendation: Some( + "Consider switching to a public DNS provider for better speed and privacy." + .to_string(), + ), + data: None, + }); + } + + emit_log("Network configuration analysis complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// Parsers +// ============================================================================= + +struct AdapterInfo { + name: String, + description: String, + mac_address: String, + dhcp_enabled: bool, + dhcp_server: String, + ipv4_address: String, + ipv6_address: String, + subnet_mask: String, + default_gateway: String, + dns_servers: Vec, + media_state: String, + admin_state: String, + interface_type: String, +} + +struct NetshInterface { + admin_state: String, + interface_type: String, +} + +fn parse_ipconfig(text: &str) -> Vec { + let mut adapters = Vec::new(); + let mut current: Option = None; + let mut collecting_dns = false; + + for line in text.lines() { + let trimmed = line.trim(); + + // New adapter section (not indented, ends with ':') + if !line.starts_with(' ') + && !line.starts_with('\t') + && line.contains("adapter") + && line.ends_with(':') + { + // Save previous + if let Some(adapter) = current.take() { + adapters.push(adapter); + } + collecting_dns = false; + let name = line + .trim_end_matches(':') + .split("adapter ") + .last() + .unwrap_or("") + .trim() + .to_string(); + current = Some(AdapterInfo { + name, + description: String::new(), + mac_address: String::new(), + dhcp_enabled: false, + dhcp_server: String::new(), + ipv4_address: String::new(), + ipv6_address: String::new(), + subnet_mask: String::new(), + default_gateway: String::new(), + dns_servers: Vec::new(), + media_state: String::new(), + admin_state: "Enabled".to_string(), + interface_type: String::new(), + }); + continue; + } + + if let Some(ref mut adapter) = current { + if trimmed.starts_with("Media State") || trimmed.starts_with("Media state") { + if let Some(val) = extract_value(trimmed) { + adapter.media_state = val; + } + collecting_dns = false; + } else if trimmed.starts_with("Description") { + if let Some(val) = extract_value(trimmed) { + adapter.description = val; + } + collecting_dns = false; + } else if trimmed.starts_with("Physical Address") { + if let Some(val) = extract_value(trimmed) { + adapter.mac_address = val; + } + collecting_dns = false; + } else if trimmed.starts_with("DHCP Enabled") { + if let Some(val) = extract_value(trimmed) { + adapter.dhcp_enabled = val.to_lowercase() == "yes"; + } + collecting_dns = false; + } else if trimmed.starts_with("DHCP Server") { + if let Some(val) = extract_value(trimmed) { + adapter.dhcp_server = val; + } + collecting_dns = false; + } else if trimmed.starts_with("IPv4 Address") + || trimmed.starts_with("Autoconfiguration IPv4") + { + if let Some(val) = extract_value(trimmed) { + adapter.ipv4_address = val.trim_end_matches("(Preferred)").trim().to_string(); + } + collecting_dns = false; + } else if trimmed.starts_with("IPv6 Address") + || trimmed.starts_with("Link-local IPv6") + || trimmed.starts_with("Temporary IPv6") + { + if adapter.ipv6_address.is_empty() { + if let Some(val) = extract_value(trimmed) { + adapter.ipv6_address = + val.trim_end_matches("(Preferred)").trim().to_string(); + } + } + collecting_dns = false; + } else if trimmed.starts_with("Subnet Mask") { + if let Some(val) = extract_value(trimmed) { + adapter.subnet_mask = val; + } + collecting_dns = false; + } else if trimmed.starts_with("Default Gateway") { + if let Some(val) = extract_value(trimmed) { + adapter.default_gateway = val; + } + collecting_dns = false; + } else if trimmed.starts_with("DNS Servers") { + if let Some(val) = extract_value(trimmed) { + if !val.is_empty() { + adapter.dns_servers.push(val); + } + } + collecting_dns = true; + } else if collecting_dns && !trimmed.is_empty() && !trimmed.contains(". . .") { + // Continuation of DNS servers (indented IPs) + let potential_ip = trimmed.to_string(); + if looks_like_ip(&potential_ip) { + adapter.dns_servers.push(potential_ip); + } else { + collecting_dns = false; + } + } else if trimmed.contains(". . .") { + collecting_dns = false; + } + } + } + + // Save last adapter + if let Some(adapter) = current { + adapters.push(adapter); + } + + adapters +} + +fn parse_netsh_interfaces(text: &str) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + + for line in text.lines().skip(3) { + // Skip header lines + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + continue; + } + + // Format: "Admin State State Type Interface Name" + // Example: "Enabled Connected Dedicated Wi-Fi" + let parts: Vec<&str> = trimmed.splitn(4, char::is_whitespace).collect(); + if parts.len() >= 4 { + // More robust parsing โ€” split on multiple spaces + let columns: Vec = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + if columns.len() >= 4 { + let admin_state = columns[0].clone(); + let interface_type = columns[2].clone(); + // Name is everything after the third column + let name = columns[3..].join(" "); + map.insert( + name, + NetshInterface { + admin_state, + interface_type, + }, + ); + } + } + } + + map +} + +fn extract_value(line: &str) -> Option { + // "Key . . . . . . : Value" โ†’ "Value" + line.split(':').nth(1).map(|v| v.trim().to_string()) +} + +fn looks_like_ip(s: &str) -> bool { + let trimmed = s.trim(); + // Simple check: contains dots or colons and no spaces + !trimmed.contains(' ') && (trimmed.contains('.') || trimmed.contains(':')) && trimmed.len() >= 3 +} diff --git a/src-tauri/src/services/ping_test.rs b/src-tauri/src/services/ping_test.rs index 3325f04..40d5cbb 100644 --- a/src-tauri/src/services/ping_test.rs +++ b/src-tauri/src/services/ping_test.rs @@ -52,6 +52,8 @@ impl Service for PingTestService { }, ], icon: "wifi".to_string(), + exclusive_resources: vec![], + dependencies: vec![], } } @@ -228,6 +230,7 @@ impl Service for PingTestService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/restore_point.rs b/src-tauri/src/services/restore_point.rs new file mode 100644 index 0000000..4894974 --- /dev/null +++ b/src-tauri/src/services/restore_point.rs @@ -0,0 +1,124 @@ +//! System Restore Point Service +//! +//! Creates a Windows system restore point before maintenance work. + +use std::time::Instant; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, ServiceResult, +}; + +pub struct RestorePointService; + +impl Service for RestorePointService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "restore-point".to_string(), + name: "System Restore Point".to_string(), + description: "Creates a system restore point before maintenance work".to_string(), + category: "maintenance".to_string(), + estimated_duration_secs: 30, + required_programs: vec![], + options: vec![ServiceOptionSchema { + id: "description".to_string(), + label: "Restore Point Description".to_string(), + option_type: "string".to_string(), + default_value: serde_json::json!("RustService Pre-Service Restore Point"), + min: None, + max: None, + options: None, + description: Some("Description for the restore point".to_string()), + }], + icon: "shield-check".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + + let description = options + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("RustService Pre-Service Restore Point") + .to_string(); + + let _ = app.emit("service-log", serde_json::json!({ + "service_id": "restore-point", + "message": format!("Creating restore point: {}", description) + })); + + match crate::commands::restore_points::create_restore_point_blocking(&description) { + Ok(msg) => { + let _ = app.emit("service-log", serde_json::json!({ + "service_id": "restore-point", + "message": format!("Restore point created: {}", msg) + })); + + ServiceResult { + service_id: "restore-point".to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings: vec![ServiceFinding { + severity: FindingSeverity::Success, + title: "Restore Point Created".to_string(), + description: format!("Successfully created restore point: {}", description), + recommendation: None, + data: Some(serde_json::json!({ + "type": "restore_point_result", + "description": description, + "success": true + })), + }], + logs: vec![msg], + agent_analysis: None, + } + } + Err(e) => { + let is_access_denied = e.to_lowercase().contains("access") + || e.to_lowercase().contains("privilege") + || e.to_lowercase().contains("administrator"); + + let _ = app.emit("service-log", serde_json::json!({ + "service_id": "restore-point", + "message": format!("Failed to create restore point: {}", e) + })); + + ServiceResult { + service_id: "restore-point".to_string(), + success: false, + error: Some(e.clone()), + duration_ms: start.elapsed().as_millis() as u64, + findings: vec![ServiceFinding { + severity: if is_access_denied { + FindingSeverity::Warning + } else { + FindingSeverity::Error + }, + title: if is_access_denied { + "Insufficient Privileges".to_string() + } else { + "Restore Point Failed".to_string() + }, + description: e, + recommendation: if is_access_denied { + Some("Run RustService as administrator to create restore points".to_string()) + } else { + Some("Check that System Protection is enabled for the system drive".to_string()) + }, + data: Some(serde_json::json!({ + "type": "restore_point_result", + "success": false + })), + }], + logs: vec![], + agent_analysis: None, + } + } + } + } +} diff --git a/src-tauri/src/services/sfc.rs b/src-tauri/src/services/sfc.rs index a344fda..ff13dde 100644 --- a/src-tauri/src/services/sfc.rs +++ b/src-tauri/src/services/sfc.rs @@ -30,6 +30,8 @@ impl Service for SfcService { required_programs: vec![], // Built-in Windows tool options: vec![], icon: "file-scan".to_string(), + exclusive_resources: vec!["disk-heavy".to_string()], + dependencies: vec!["dism".to_string()], } } @@ -80,6 +82,7 @@ impl Service for SfcService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -216,6 +219,7 @@ impl Service for SfcService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/smartctl.rs b/src-tauri/src/services/smartctl.rs index 1e6cde9..fd32a5e 100644 --- a/src-tauri/src/services/smartctl.rs +++ b/src-tauri/src/services/smartctl.rs @@ -32,6 +32,8 @@ impl Service for SmartctlService { required_programs: vec!["smartctl".to_string()], options: vec![], icon: "activity".to_string(), + exclusive_resources: vec![], + dependencies: vec![], } } @@ -69,6 +71,7 @@ impl Service for SmartctlService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } Err(e) => { @@ -79,6 +82,7 @@ impl Service for SmartctlService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } }; @@ -269,6 +273,7 @@ impl Service for SmartctlService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } @@ -319,7 +324,7 @@ struct NvmeHealthLog { power_on_hours: Option, unsafe_shutdowns: Option, media_errors: Option, - num_err_log_entries: Option, + _num_err_log_entries: Option, data_units_written: Option, data_units_read: Option, temperature_sensors: Option>, diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index 868f668..e226315 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -32,6 +32,8 @@ impl Service for SpeedtestService { required_programs: vec!["speedtest".to_string()], options: vec![], icon: "download".to_string(), + exclusive_resources: vec!["network-bandwidth".to_string()], + dependencies: vec![], } } @@ -69,6 +71,7 @@ impl Service for SpeedtestService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } Err(e) => { @@ -79,6 +82,7 @@ impl Service for SpeedtestService { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } }; @@ -203,6 +207,7 @@ impl Service for SpeedtestService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/startup_optimize.rs b/src-tauri/src/services/startup_optimize.rs new file mode 100644 index 0000000..1bbcb19 --- /dev/null +++ b/src-tauri/src/services/startup_optimize.rs @@ -0,0 +1,802 @@ +//! Startup Optimizer Service +//! +//! Enumerates all Windows startup items, sends them to the user's configured +//! LLM for intelligent classification (essential / useful / unnecessary), +//! and optionally disables the unnecessary ones to improve boot time. +//! +//! Falls back to heuristic pattern matching when no AI provider is configured. + +use std::collections::HashMap; +use std::time::Instant; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tauri::{AppHandle, Emitter}; + +use crate::commands::get_settings; +use crate::commands::startup::{ + get_registry_startup_items_sync, get_scheduled_startup_tasks_sync, + get_startup_folder_items_sync, toggle_registry_startup_item_sync, + toggle_scheduled_task_sync, StartupItem, StartupSource, +}; +use crate::services::Service; +use crate::types::{ + AgentProvider, FindingSeverity, ServiceDefinition, ServiceFinding, ServiceOptionSchema, + ServiceResult, +}; + +// ============================================================================= +// Classification +// ============================================================================= + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum Classification { + Essential, + Useful, + Unnecessary, +} + +impl Classification { + fn as_str(&self) -> &'static str { + match self { + Classification::Essential => "essential", + Classification::Useful => "useful", + Classification::Unnecessary => "unnecessary", + } + } +} + +// ============================================================================= +// AI Classification +// ============================================================================= + +/// Build the prompt for the LLM to classify startup items. +fn build_classification_prompt(items: &[&StartupItem]) -> String { + let mut prompt = String::from( + r#"You are a Windows startup optimization expert. Classify each startup item as exactly one of: +- "essential": System-critical items that should NEVER be disabled (security software, hardware drivers, audio/GPU/touchpad/Bluetooth drivers, OEM hardware utilities, core Windows services) +- "useful": Items the user likely wants but that aren't system-critical (productivity tools they actively use, VPN clients, backup software). When in doubt, classify as "useful" to be conservative. +- "unnecessary": Items safe to disable for faster boot (game launchers, social/media apps that auto-start, cloud sync that can be started manually, software updaters, old utilities) + +For each item, consider the name, command path, publisher, and source. Be conservative โ€” only mark items as "unnecessary" when you're confident they aren't needed at startup. + +Respond with ONLY a valid JSON object mapping each item number to its classification. Example: +{"1": "essential", "2": "unnecessary", "3": "useful"} + +Items to classify: +"#, + ); + + for (i, item) in items.iter().enumerate() { + prompt.push_str(&format!( + "\n{}. Name: \"{}\" | Command: \"{}\" | Publisher: \"{}\" | Source: {} | Enabled: {}", + i + 1, + item.name, + item.command, + item.publisher.as_deref().unwrap_or("Unknown"), + item.source, + item.enabled, + )); + } + + prompt +} + +/// Get the API base URL and key for the configured provider. +fn get_provider_config() -> Option<(String, String, String)> { + let settings = get_settings().ok()?; + let agent = &settings.agent; + + let (base_url, api_key, model) = match agent.provider { + AgentProvider::OpenAI => ( + "https://api.openai.com/v1".to_string(), + agent.api_keys.openai.clone()?, + if agent.model.is_empty() { "gpt-4o-mini".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Anthropic => ( + "https://api.anthropic.com".to_string(), + agent.api_keys.anthropic.clone()?, + if agent.model.is_empty() { "claude-sonnet-4-20250514".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Google => ( + "https://generativelanguage.googleapis.com".to_string(), + agent.api_keys.google.clone()?, + if agent.model.is_empty() { "gemini-2.0-flash".to_string() } else { agent.model.clone() }, + ), + AgentProvider::XAI => ( + "https://api.x.ai/v1".to_string(), + agent.api_keys.xai.clone()?, + if agent.model.is_empty() { "grok-2-latest".to_string() } else { agent.model.clone() }, + ), + AgentProvider::DeepSeek => ( + "https://api.deepseek.com/v1".to_string(), + agent.api_keys.deepseek.clone()?, + if agent.model.is_empty() { "deepseek-chat".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Groq => ( + "https://api.groq.com/openai/v1".to_string(), + agent.api_keys.groq.clone()?, + if agent.model.is_empty() { "llama-3.3-70b-versatile".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Mistral => ( + "https://api.mistral.ai/v1".to_string(), + agent.api_keys.mistral.clone()?, + if agent.model.is_empty() { "mistral-small-latest".to_string() } else { agent.model.clone() }, + ), + AgentProvider::OpenRouter => ( + "https://openrouter.ai/api/v1".to_string(), + agent.api_keys.openrouter.clone()?, + if agent.model.is_empty() { "openai/gpt-4o-mini".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Ollama => ( + agent.base_url.clone().unwrap_or_else(|| "http://localhost:11434".to_string()), + "ollama".to_string(), // Ollama doesn't need a real key + if agent.model.is_empty() { "llama3.2".to_string() } else { agent.model.clone() }, + ), + AgentProvider::Custom => ( + agent.base_url.clone()?, + agent.api_keys.custom.clone().unwrap_or_default(), + agent.model.clone(), + ), + }; + + // Skip if API key is empty (except Ollama) + if api_key.is_empty() && agent.provider != AgentProvider::Ollama { + return None; + } + + Some((base_url, api_key, model)) +} + +/// Call the LLM using the OpenAI-compatible chat completions API. +/// Anthropic and Google use different APIs, handled separately. +fn call_llm_sync(prompt: &str, base_url: &str, api_key: &str, model: &str, provider: &AgentProvider) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + match provider { + AgentProvider::Anthropic => { + // Anthropic Messages API + let resp = client + .post(format!("{}/v1/messages", base_url)) + .header("x-api-key", api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&json!({ + "model": model, + "max_tokens": 2048, + "messages": [{"role": "user", "content": prompt}], + })) + .send() + .map_err(|e| format!("Anthropic API request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("Anthropic API error {}: {}", status, body)); + } + + let body: serde_json::Value = resp.json() + .map_err(|e| format!("Failed to parse Anthropic response: {}", e))?; + + body["content"][0]["text"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No text in Anthropic response".to_string()) + } + AgentProvider::Google => { + // Google Gemini API + let url = format!( + "{}/v1beta/models/{}:generateContent?key={}", + base_url, model, api_key + ); + let resp = client + .post(&url) + .header("content-type", "application/json") + .json(&json!({ + "contents": [{"parts": [{"text": prompt}]}], + })) + .send() + .map_err(|e| format!("Google API request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("Google API error {}: {}", status, body)); + } + + let body: serde_json::Value = resp.json() + .map_err(|e| format!("Failed to parse Google response: {}", e))?; + + body["candidates"][0]["content"]["parts"][0]["text"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No text in Google response".to_string()) + } + AgentProvider::Ollama => { + // Ollama uses OpenAI-compatible API at /v1/chat/completions + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + let resp = client + .post(&url) + .header("content-type", "application/json") + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + })) + .send() + .map_err(|e| format!("Ollama API request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("Ollama API error {}: {}", status, body)); + } + + let body: serde_json::Value = resp.json() + .map_err(|e| format!("Failed to parse Ollama response: {}", e))?; + + body["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No content in Ollama response".to_string()) + } + _ => { + // OpenAI-compatible API (OpenAI, xAI, DeepSeek, Groq, Mistral, OpenRouter, Custom) + let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .header("content-type", "application/json") + .json(&json!({ + "model": model, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.1, + "response_format": {"type": "json_object"}, + })) + .send() + .map_err(|e| format!("LLM API request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("LLM API error {}: {}", status, body)); + } + + let body: serde_json::Value = resp.json() + .map_err(|e| format!("Failed to parse LLM response: {}", e))?; + + body["choices"][0]["message"]["content"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "No content in LLM response".to_string()) + } + } +} + +/// Parse the LLM's JSON response into a classification map. +fn parse_ai_classifications(response: &str, count: usize) -> HashMap { + let mut result = HashMap::new(); + + // Extract JSON from response (LLM might wrap it in markdown code blocks) + let json_str = response + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + + if let Ok(parsed) = serde_json::from_str::>(json_str) { + for (key, value) in &parsed { + if let Ok(idx) = key.parse::() { + if idx >= 1 && idx <= count { + let class = match value.to_lowercase().as_str() { + "essential" => Classification::Essential, + "unnecessary" => Classification::Unnecessary, + _ => Classification::Useful, + }; + result.insert(idx - 1, class); // Convert to 0-indexed + } + } + } + } + + result +} + +/// Classify items using the configured LLM. Returns classifications + whether AI was used. +fn classify_with_ai( + items: &[StartupItem], + logs: &mut Vec, + app: &AppHandle, + emit_log: &dyn Fn(&str, &mut Vec, &AppHandle), +) -> (Vec<(Classification, usize)>, bool) { + let item_refs: Vec<&StartupItem> = items.iter().collect(); + + // Try AI classification + if let Some((base_url, api_key, model)) = get_provider_config() { + let settings = get_settings().ok(); + let provider = settings + .as_ref() + .map(|s| s.agent.provider.clone()) + .unwrap_or_default(); + + emit_log( + &format!("Using AI classification ({:?} / {})", provider, model), + logs, + app, + ); + + let prompt = build_classification_prompt(&item_refs); + + match call_llm_sync(&prompt, &base_url, &api_key, &model, &provider) { + Ok(response) => { + let ai_map = parse_ai_classifications(&response, items.len()); + + if ai_map.len() >= items.len() / 2 { + // AI classified at least half โ€” use its results + emit_log( + &format!("AI classified {}/{} items successfully", ai_map.len(), items.len()), + logs, + app, + ); + + let classified: Vec<(Classification, usize)> = (0..items.len()) + .map(|i| { + let class = ai_map.get(&i).copied().unwrap_or(Classification::Useful); + (class, i) + }) + .collect(); + + return (classified, true); + } else { + emit_log( + &format!( + "AI returned incomplete results ({}/{}), falling back to heuristics", + ai_map.len(), + items.len() + ), + logs, + app, + ); + } + } + Err(e) => { + emit_log( + &format!("AI classification failed: {}. Falling back to heuristics.", e), + logs, + app, + ); + } + } + } else { + emit_log( + "No AI provider configured โ€” using heuristic classification", + logs, + app, + ); + } + + // Fallback: heuristic classification + let classified: Vec<(Classification, usize)> = (0..items.len()) + .map(|i| (classify_item_heuristic(&items[i]), i)) + .collect(); + + (classified, false) +} + +// ============================================================================= +// Heuristic Fallback Classification +// ============================================================================= + +/// Patterns that indicate an **essential** startup item (never disable). +const ESSENTIAL_PATTERNS: &[&str] = &[ + "windows security", "securityhealth", "windowsdefender", "msascui", "windows defender", + "realtek", "rtkngui", "rthdvcpl", "dolby", "nahimic", "waves maxxaudio", "conexant", "idt audio", + "synaptics", "elantech", "etdctrl", "alps pointing", "trackpad", "touchpad", "precision touchpad", + "nvidia", "nvcontainer", "amd radeon", "amdrsserv", "intel graphics", "igfx", + "bluetooth", + "lenovo", "dell", "hp ", "asus ", "acer ", "razer synapse", "corsair", "logitech", "wacom", "steelseries", +]; + +/// Patterns that indicate an **unnecessary** startup item (safe to disable). +const UNNECESSARY_PATTERNS: &[&str] = &[ + "steam", "steamwebhelper", "epicgameslauncher", "epic games", "origin", "battle.net", + "gog galaxy", "ubisoft", "uplay", "riotclient", "ea app", + "discord", "spotify", "skype", "telegram", "slack", "zoom", "whatsapp", "viber", "signal", + "onedrive", "dropbox", "google drive", "googledrivesync", "icloud", "box sync", "mega", + "googleupdate", "google update", "msedge update", "adobe update", "adobearm", "jusched", "java update", + "itunes", "ituneshelper", "apple push", "amazon music", + "ccleaner", "utorrent", "bittorrent", "teamviewer", "anydesk", "parsec", "wallpaper engine", +]; + +/// Publishers that default to essential (system / OEM) unless overridden. +const ESSENTIAL_PUBLISHERS: &[&str] = &[ + "microsoft", "windows", "intel", "amd", "nvidia", "realtek", "synaptics", + "dell", "lenovo", "hewlett", "hp inc", "asus", "acer", +]; + +fn classify_item_heuristic(item: &StartupItem) -> Classification { + let name_lower = item.name.to_lowercase(); + let cmd_lower = item.command.to_lowercase(); + let pub_lower = item.publisher.as_deref().unwrap_or("").to_lowercase(); + + if UNNECESSARY_PATTERNS.iter().any(|p| name_lower.contains(p) || cmd_lower.contains(p)) { + return Classification::Unnecessary; + } + if ESSENTIAL_PATTERNS.iter().any(|p| name_lower.contains(p) || cmd_lower.contains(p)) { + return Classification::Essential; + } + if !pub_lower.is_empty() && ESSENTIAL_PUBLISHERS.iter().any(|p| pub_lower.contains(p)) { + return Classification::Essential; + } + Classification::Useful +} + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct StartupOptimizeService; + +impl Service for StartupOptimizeService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "startup-optimize".to_string(), + name: "Startup Optimizer".to_string(), + description: + "AI-powered analysis of startup programs โ€” classifies and optionally disables unnecessary items to improve boot time" + .to_string(), + category: "maintenance".to_string(), + estimated_duration_secs: 20, + required_programs: vec![], + options: vec![ + ServiceOptionSchema { + id: "disable_unnecessary".to_string(), + label: "Disable Unnecessary Items".to_string(), + option_type: "boolean".to_string(), + default_value: json!(false), + min: None, + max: None, + options: None, + description: Some( + "Actually disable unnecessary startup items (otherwise report only)" + .to_string(), + ), + }, + ServiceOptionSchema { + id: "include_disabled".to_string(), + label: "Include Disabled Items".to_string(), + option_type: "boolean".to_string(), + default_value: json!(false), + min: None, + max: None, + options: None, + description: Some( + "Include already-disabled startup items in the report".to_string(), + ), + }, + ], + icon: "rocket".to_string(), + exclusive_resources: vec![], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "startup-optimize"; + + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + let disable_unnecessary = options + .get("disable_unnecessary") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let include_disabled = options + .get("include_disabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // ----------------------------------------------------------------- + // Enumerate startup items + // ----------------------------------------------------------------- + emit_log("Enumerating startup items...", &mut logs, app); + + let mut items: Vec = Vec::new(); + + match get_registry_startup_items_sync() { + Ok(mut v) => items.append(&mut v), + Err(e) => emit_log( + &format!("Warning: failed to get registry startup items: {}", e), + &mut logs, + app, + ), + } + + match get_startup_folder_items_sync() { + Ok(mut v) => items.append(&mut v), + Err(e) => emit_log( + &format!("Warning: failed to get startup folder items: {}", e), + &mut logs, + app, + ), + } + + match get_scheduled_startup_tasks_sync() { + Ok(mut v) => items.append(&mut v), + Err(e) => emit_log( + &format!("Warning: failed to get scheduled startup tasks: {}", e), + &mut logs, + app, + ), + } + + emit_log( + &format!("Found {} startup items", items.len()), + &mut logs, + app, + ); + + // Optionally filter out already-disabled items + if !include_disabled { + items.retain(|i| i.enabled); + } + + if items.is_empty() { + emit_log("No startup items found.", &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Success, + title: "No startup items found".to_string(), + description: "No startup items were detected on this system.".to_string(), + recommendation: None, + data: Some(json!({ + "type": "startup_optimize", + "mode": if disable_unnecessary { "disable" } else { "report" }, + "aiPowered": false, + "totalItems": 0, + "essentialCount": 0, + "usefulCount": 0, + "unnecessaryCount": 0, + "disabledThisRun": [], + "failedItems": [], + "items": [], + })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + // ----------------------------------------------------------------- + // Classify (AI-powered with heuristic fallback) + // ----------------------------------------------------------------- + emit_log("Classifying startup items...", &mut logs, app); + + let (classified, ai_powered) = classify_with_ai(&items, &mut logs, app, &emit_log); + + let essential_count = classified.iter().filter(|(c, _)| *c == Classification::Essential).count(); + let useful_count = classified.iter().filter(|(c, _)| *c == Classification::Useful).count(); + let unnecessary_count = classified.iter().filter(|(c, _)| *c == Classification::Unnecessary).count(); + let unnecessary_enabled: Vec = classified + .iter() + .filter(|(c, i)| *c == Classification::Unnecessary && items[*i].enabled) + .map(|(_, i)| *i) + .collect(); + + emit_log( + &format!( + "Essential: {}, Useful: {}, Unnecessary: {} ({})", + essential_count, useful_count, unnecessary_count, + if ai_powered { "AI-classified" } else { "heuristic" }, + ), + &mut logs, + app, + ); + + // ----------------------------------------------------------------- + // Optionally disable + // ----------------------------------------------------------------- + let mut disabled_items: Vec = Vec::new(); + let mut failed_items: Vec<(String, String)> = Vec::new(); + + if disable_unnecessary && !unnecessary_enabled.is_empty() { + emit_log("Disabling unnecessary startup items...", &mut logs, app); + + for &idx in &unnecessary_enabled { + let item = &items[idx]; + + // Skip startup folder items (require deletion) + if item.source == StartupSource::StartupFolderUser + || item.source == StartupSource::StartupFolderAllUsers + { + emit_log( + &format!( + "Skipping '{}' (startup folder item โ€” remove manually)", + item.name + ), + &mut logs, + app, + ); + continue; + } + + let result = if item.id.starts_with("reg_") { + toggle_registry_startup_item_sync(&item.id[4..], false) + } else if item.id.starts_with("task_") { + toggle_scheduled_task_sync(&item.id[5..], false) + } else { + Err(format!("Unknown item type: {}", item.id)) + }; + + match result { + Ok(()) => { + emit_log( + &format!("Disabled: {}", item.name), + &mut logs, + app, + ); + disabled_items.push(item.name.clone()); + } + Err(e) => { + emit_log( + &format!("Failed to disable '{}': {}", item.name, e), + &mut logs, + app, + ); + failed_items.push((item.name.clone(), e)); + } + } + } + } + + // ----------------------------------------------------------------- + // Build findings + // ----------------------------------------------------------------- + + let items_data: Vec = classified + .iter() + .map(|(class, idx)| { + let item = &items[*idx]; + json!({ + "id": item.id, + "name": item.name, + "command": item.command, + "source": item.source, + "sourceLocation": item.source_location, + "enabled": item.enabled, + "publisher": item.publisher, + "description": item.description, + "classification": class.as_str(), + "disabledThisRun": disabled_items.contains(&item.name), + }) + }) + .collect(); + + let mode = if disable_unnecessary { "disable" } else { "report" }; + + let severity = if unnecessary_enabled.is_empty() { + FindingSeverity::Success + } else if disable_unnecessary && failed_items.is_empty() { + FindingSeverity::Success + } else { + FindingSeverity::Warning + }; + + let title = if disable_unnecessary { + if disabled_items.is_empty() { + "No unnecessary items to disable".to_string() + } else { + format!("Disabled {} unnecessary startup item(s)", disabled_items.len()) + } + } else if unnecessary_enabled.is_empty() { + "No unnecessary startup items found".to_string() + } else { + format!( + "{} unnecessary startup item(s) found", + unnecessary_enabled.len() + ) + }; + + let description = format!( + "{} startup items analyzed{}: {} essential, {} useful, {} unnecessary.", + classified.len(), + if ai_powered { " by AI" } else { "" }, + essential_count, + useful_count, + unnecessary_count, + ); + + let recommendation = if !disable_unnecessary && !unnecessary_enabled.is_empty() { + Some("Re-run with \"Disable Unnecessary Items\" enabled to automatically disable these items.".to_string()) + } else { + None + }; + + findings.push(ServiceFinding { + severity, + title, + description, + recommendation, + data: Some(json!({ + "type": "startup_optimize", + "mode": mode, + "aiPowered": ai_powered, + "totalItems": classified.len(), + "essentialCount": essential_count, + "usefulCount": useful_count, + "unnecessaryCount": unnecessary_count, + "disabledThisRun": disabled_items, + "failedItems": failed_items.iter().map(|(n, e)| json!({"name": n, "error": e})).collect::>(), + "items": items_data, + })), + }); + + // Individual warnings for unnecessary enabled items (report mode) + if !disable_unnecessary { + for &idx in &unnecessary_enabled { + let item = &items[idx]; + findings.push(ServiceFinding { + severity: FindingSeverity::Warning, + title: format!("Unnecessary: {}", item.name), + description: format!( + "Source: {} | Publisher: {} | Command: {}", + item.source, + item.publisher.as_deref().unwrap_or("Unknown"), + item.command, + ), + recommendation: Some( + "Consider disabling this startup item to improve boot time.".to_string(), + ), + data: None, + }); + } + } + + // Failures + for (name, error) in &failed_items { + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: format!("Failed to disable: {}", name), + description: error.clone(), + recommendation: Some("Try running as administrator.".to_string()), + data: None, + }); + } + + emit_log("Startup optimization complete.", &mut logs, app); + + ServiceResult { + service_id: service_id.to_string(), + success: true, + error: None, + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} diff --git a/src-tauri/src/services/stinger.rs b/src-tauri/src/services/stinger.rs index 9ea5e1d..eb0e00d 100644 --- a/src-tauri/src/services/stinger.rs +++ b/src-tauri/src/services/stinger.rs @@ -77,6 +77,8 @@ impl Service for StingerService { }, ], icon: "bug".to_string(), + exclusive_resources: vec!["filesystem-scan".to_string()], + dependencies: vec![], } } @@ -123,6 +125,7 @@ impl Service for StingerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } Err(e) => { @@ -138,6 +141,7 @@ impl Service for StingerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -261,6 +265,7 @@ impl Service for StingerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -411,6 +416,7 @@ impl Service for StingerService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/usb_stability.rs b/src-tauri/src/services/usb_stability.rs new file mode 100644 index 0000000..3b6c3b2 --- /dev/null +++ b/src-tauri/src/services/usb_stability.rs @@ -0,0 +1,1041 @@ +//! USB Stability Test Service +//! +//! Non-destructive USB drive testing: sequential read/write benchmarks, +//! data integrity verification, random I/O latency, and fake-capacity detection. +//! Uses temporary files that are cleaned up after the test. + +use std::fs; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; +use std::time::Instant; + +use chrono::Utc; +use serde_json::json; +use sysinfo::Disks; +use tauri::{AppHandle, Emitter}; + +use crate::services::Service; +use crate::types::{ + FindingSeverity, SelectOption, ServiceDefinition, ServiceFinding, ServiceOptionSchema, + ServiceResult, +}; + +// ============================================================================= +// Constants +// ============================================================================= + +const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB chunks +const RANDOM_IO_BLOCK: usize = 4096; // 4 KB random I/O blocks +const RANDOM_IO_ITERATIONS: usize = 100; +const PATTERN_BYTE_A: u8 = 0xAA; +const PATTERN_BYTE_B: u8 = 0x55; +const TEMP_DIR_NAME: &str = "_rustservice_usb_test"; + +// ============================================================================= +// Service Implementation +// ============================================================================= + +pub struct UsbStabilityService; + +impl Service for UsbStabilityService { + fn definition(&self) -> ServiceDefinition { + ServiceDefinition { + id: "usb-stability".to_string(), + name: "USB Stability Test".to_string(), + description: + "Tests USB drive speed, integrity, and reliability with non-destructive benchmarks" + .to_string(), + category: "diagnostics".to_string(), + estimated_duration_secs: 120, + required_programs: vec![], + options: vec![ + ServiceOptionSchema { + id: "target_drive".to_string(), + label: "Target USB Drive".to_string(), + option_type: "usb_drive".to_string(), + default_value: serde_json::json!(""), + min: None, + max: None, + options: None, + description: Some( + "Select the USB drive to test. Leave empty to auto-detect.".to_string(), + ), + }, + ServiceOptionSchema { + id: "test_intensity".to_string(), + label: "Test Intensity".to_string(), + option_type: "select".to_string(), + default_value: serde_json::json!("standard"), + min: None, + max: None, + options: Some(vec![ + SelectOption { + value: "quick".to_string(), + label: "Quick (256 MB)".to_string(), + }, + SelectOption { + value: "standard".to_string(), + label: "Standard (512 MB)".to_string(), + }, + SelectOption { + value: "thorough".to_string(), + label: "Thorough (1 GB)".to_string(), + }, + ]), + description: Some("Amount of data to write for benchmarking".to_string()), + }, + ServiceOptionSchema { + id: "verify_integrity".to_string(), + label: "Data Integrity Check".to_string(), + option_type: "boolean".to_string(), + default_value: serde_json::json!(true), + min: None, + max: None, + options: None, + description: Some( + "Verify written data byte-by-byte to detect corruption or fake drives" + .to_string(), + ), + }, + ], + icon: "usb".to_string(), + exclusive_resources: vec!["disk-heavy".to_string()], + dependencies: vec![], + } + } + + fn run(&self, options: &serde_json::Value, app: &AppHandle) -> ServiceResult { + let start = Instant::now(); + let mut logs: Vec = Vec::new(); + let mut findings: Vec = Vec::new(); + let service_id = "usb-stability"; + + // Emit log helper + let emit_log = |log: &str, logs: &mut Vec, app: &AppHandle| { + logs.push(log.to_string()); + let _ = app.emit( + "service-log", + json!({ + "serviceId": service_id, + "log": log, + "timestamp": Utc::now().to_rfc3339() + }), + ); + }; + + emit_log("Starting USB Stability Test...", &mut logs, app); + + // Parse options + let target_drive = options + .get("target_drive") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let intensity = options + .get("test_intensity") + .and_then(|v| v.as_str()) + .unwrap_or("standard"); + + let verify_integrity = options + .get("verify_integrity") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let test_size_bytes: u64 = match intensity { + "quick" => 256 * 1024 * 1024, + "thorough" => 1024 * 1024 * 1024, + _ => 512 * 1024 * 1024, // standard + }; + + let test_size_mb = test_size_bytes / (1024 * 1024); + + // ===================================================================== + // Phase 1: Drive Detection & Validation + // ===================================================================== + emit_log("Phase 1: Detecting USB drives...", &mut logs, app); + + let disks = Disks::new_with_refreshed_list(); + let removable_drives: Vec<_> = disks + .list() + .iter() + .filter(|d| d.is_removable() && d.total_space() > 0) + .collect(); + + if removable_drives.is_empty() { + emit_log("ERROR: No removable USB drives detected!", &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "No USB Drives Found".to_string(), + description: "No removable USB drives were detected on this system. Please connect a USB drive and try again.".to_string(), + recommendation: Some("Insert a USB drive and ensure it is recognized by Windows before running this test.".to_string()), + data: Some(json!({ "type": "usb_error", "error": "no_drives" })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some("No USB drives detected".to_string()), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + // Select the target drive + let selected_disk = if target_drive.is_empty() { + emit_log( + "No drive specified โ€” auto-detecting first removable drive...", + &mut logs, + app, + ); + removable_drives.first().copied() + } else { + removable_drives + .iter() + .find(|d| { + d.mount_point() + .to_string_lossy() + .trim_end_matches('\\') + .eq_ignore_ascii_case(target_drive.trim_end_matches('\\')) + }) + .copied() + }; + + let selected_disk = match selected_disk { + Some(d) => d, + None => { + let msg = format!( + "Drive '{}' not found or is not a removable USB drive", + target_drive + ); + emit_log(&format!("ERROR: {}", msg), &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Drive Not Found".to_string(), + description: msg.clone(), + recommendation: Some( + "Check that the USB drive is properly connected and try again.".to_string(), + ), + data: Some(json!({ "type": "usb_error", "error": "drive_not_found" })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(msg), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + }; + + let mount_point = selected_disk.mount_point().to_string_lossy().to_string(); + let total_space = selected_disk.total_space(); + let available_space = selected_disk.available_space(); + let fs_type = selected_disk.file_system().to_string_lossy().to_string(); + let volume_name = selected_disk.name().to_string_lossy().to_string(); + let volume_label = if volume_name.is_empty() { + "Removable Disk".to_string() + } else { + volume_name + }; + + emit_log( + &format!( + "Selected drive: {} ({}) โ€” {:.1} GB total, {:.1} GB free, {}", + mount_point, + volume_label, + total_space as f64 / 1_073_741_824.0, + available_space as f64 / 1_073_741_824.0, + fs_type + ), + &mut logs, + app, + ); + + // Check free space + if available_space < test_size_bytes + (10 * 1024 * 1024) { + let msg = format!( + "Insufficient free space on {}. Need {:.0} MB free, only {:.0} MB available.", + mount_point, + test_size_bytes as f64 / 1_048_576.0 + 10.0, + available_space as f64 / 1_048_576.0 + ); + emit_log(&format!("ERROR: {}", msg), &mut logs, app); + findings.push(ServiceFinding { + severity: FindingSeverity::Error, + title: "Insufficient Free Space".to_string(), + description: msg.clone(), + recommendation: Some( + "Free up space on the USB drive or use a lower test intensity.".to_string(), + ), + data: Some(json!({ "type": "usb_error", "error": "insufficient_space" })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(msg), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + // Create temp directory + let temp_dir = PathBuf::from(&mount_point).join(TEMP_DIR_NAME); + let test_file = temp_dir.join("stability_test.bin"); + + // Ensure cleanup on all exit paths + let cleanup = |dir: &PathBuf, logs: &mut Vec, app: &AppHandle| { + if dir.exists() { + match fs::remove_dir_all(dir) { + Ok(()) => { + emit_log("Cleanup: Temporary test files removed", logs, app); + } + Err(e) => { + emit_log( + &format!("Warning: Failed to clean up temp files: {}", e), + logs, + app, + ); + } + } + } + }; + + if let Err(e) = fs::create_dir_all(&temp_dir) { + let msg = format!("Failed to create temp directory on {}: {}", mount_point, e); + emit_log(&format!("ERROR: {}", msg), &mut logs, app); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(msg), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + // ===================================================================== + // Phase 2: Sequential Write Speed Test + // ===================================================================== + emit_log( + &format!("Phase 2: Sequential write test ({} MB)...", test_size_mb), + &mut logs, + app, + ); + + let pattern_chunk: Vec = (0..CHUNK_SIZE) + .map(|i| { + if i % 2 == 0 { + PATTERN_BYTE_A + } else { + PATTERN_BYTE_B + } + }) + .collect(); + + let total_chunks = (test_size_bytes as usize) / CHUNK_SIZE; + let write_start = Instant::now(); + let mut bytes_written: u64 = 0; + + let write_result = (|| -> Result<(), String> { + let mut file = fs::File::create(&test_file) + .map_err(|e| format!("Failed to create test file: {}", e))?; + + for i in 0..total_chunks { + file.write_all(&pattern_chunk) + .map_err(|e| format!("Write error at chunk {}: {}", i, e))?; + bytes_written += CHUNK_SIZE as u64; + + // Log progress every 10% + let progress = ((i + 1) as f64 / total_chunks as f64 * 100.0) as u32; + if progress % 10 == 0 && (i + 1) % (total_chunks / 10).max(1) == 0 { + emit_log( + &format!( + " Write progress: {}% ({} MB)", + progress, + bytes_written / 1_048_576 + ), + &mut logs, + app, + ); + } + } + file.sync_all() + .map_err(|e| format!("Failed to sync: {}", e))?; + Ok(()) + })(); + + let write_duration = write_start.elapsed(); + let write_speed_mbps = if write_duration.as_secs_f64() > 0.0 { + bytes_written as f64 / 1_048_576.0 / write_duration.as_secs_f64() + } else { + 0.0 + }; + + if let Err(e) = write_result { + emit_log(&format!("ERROR: Write test failed: {}", e), &mut logs, app); + cleanup(&temp_dir, &mut logs, app); + + findings.push(ServiceFinding { + severity: FindingSeverity::Critical, + title: "Write Test Failed".to_string(), + description: e.clone(), + recommendation: Some("The USB drive may be write-protected, corrupted, or failing. Try a different USB port or drive.".to_string()), + data: Some(json!({ "type": "usb_error", "error": "write_failed" })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(e), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + emit_log( + &format!( + "Write complete: {:.1} MB/s ({:.1}s for {} MB)", + write_speed_mbps, + write_duration.as_secs_f64(), + test_size_mb + ), + &mut logs, + app, + ); + + let write_rating = rate_speed(write_speed_mbps, false); + findings.push(ServiceFinding { + severity: write_rating.severity.clone(), + title: format!("Sequential Write: {:.1} MB/s", write_speed_mbps), + description: format!( + "Wrote {} MB in {:.1} seconds. {}", + test_size_mb, + write_duration.as_secs_f64(), + write_rating.description + ), + recommendation: write_rating.recommendation.clone(), + data: Some(json!({ + "type": "usb_write_speed", + "speedMbps": write_speed_mbps, + "bytesWritten": bytes_written, + "durationSecs": write_duration.as_secs_f64(), + "rating": write_rating.label, + })), + }); + + // ===================================================================== + // Phase 3: Sequential Read Speed Test + // ===================================================================== + emit_log( + &format!("Phase 3: Sequential read test ({} MB)...", test_size_mb), + &mut logs, + app, + ); + + let read_start = Instant::now(); + let mut bytes_read: u64 = 0; + let mut read_buf = vec![0u8; CHUNK_SIZE]; + + let read_result = (|| -> Result<(), String> { + let mut file = fs::File::open(&test_file) + .map_err(|e| format!("Failed to open test file for reading: {}", e))?; + + loop { + let n = file + .read(&mut read_buf) + .map_err(|e| format!("Read error: {}", e))?; + if n == 0 { + break; + } + bytes_read += n as u64; + + let progress = (bytes_read as f64 / test_size_bytes as f64 * 100.0) as u32; + if progress % 10 == 0 + && bytes_read % (test_size_bytes / 10).max(1) < CHUNK_SIZE as u64 + { + emit_log( + &format!( + " Read progress: {}% ({} MB)", + progress, + bytes_read / 1_048_576 + ), + &mut logs, + app, + ); + } + } + Ok(()) + })(); + + let read_duration = read_start.elapsed(); + let read_speed_mbps = if read_duration.as_secs_f64() > 0.0 { + bytes_read as f64 / 1_048_576.0 / read_duration.as_secs_f64() + } else { + 0.0 + }; + + if let Err(e) = read_result { + emit_log(&format!("ERROR: Read test failed: {}", e), &mut logs, app); + cleanup(&temp_dir, &mut logs, app); + + findings.push(ServiceFinding { + severity: FindingSeverity::Critical, + title: "Read Test Failed".to_string(), + description: e.clone(), + recommendation: Some( + "The USB drive may be corrupted or disconnected during the test.".to_string(), + ), + data: Some(json!({ "type": "usb_error", "error": "read_failed" })), + }); + return ServiceResult { + service_id: service_id.to_string(), + success: false, + error: Some(e), + duration_ms: start.elapsed().as_millis() as u64, + findings, + logs, + agent_analysis: None, + }; + } + + emit_log( + &format!( + "Read complete: {:.1} MB/s ({:.1}s for {} MB)", + read_speed_mbps, + read_duration.as_secs_f64(), + test_size_mb + ), + &mut logs, + app, + ); + + let read_rating = rate_speed(read_speed_mbps, true); + findings.push(ServiceFinding { + severity: read_rating.severity.clone(), + title: format!("Sequential Read: {:.1} MB/s", read_speed_mbps), + description: format!( + "Read {} MB in {:.1} seconds. {}", + test_size_mb, + read_duration.as_secs_f64(), + read_rating.description + ), + recommendation: read_rating.recommendation.clone(), + data: Some(json!({ + "type": "usb_read_speed", + "speedMbps": read_speed_mbps, + "bytesRead": bytes_read, + "durationSecs": read_duration.as_secs_f64(), + "rating": read_rating.label, + })), + }); + + // ===================================================================== + // Phase 4: Data Integrity Verification + // ===================================================================== + let mut integrity_pass = true; + let mut integrity_errors: u64 = 0; + let mut first_error_offset: Option = None; + + if verify_integrity { + emit_log("Phase 4: Data integrity verification...", &mut logs, app); + + let verify_result = (|| -> Result<(bool, u64, Option), String> { + let mut file = fs::File::open(&test_file) + .map_err(|e| format!("Failed to open test file for verification: {}", e))?; + + let mut verify_buf = vec![0u8; CHUNK_SIZE]; + let mut offset: u64 = 0; + let mut errors: u64 = 0; + let mut first_err: Option = None; + + loop { + let n = file + .read(&mut verify_buf) + .map_err(|e| format!("Read error during verification: {}", e))?; + if n == 0 { + break; + } + + for i in 0..n { + let expected = if i % 2 == 0 { + PATTERN_BYTE_A + } else { + PATTERN_BYTE_B + }; + if verify_buf[i] != expected { + errors += 1; + if first_err.is_none() { + first_err = Some(offset + i as u64); + } + } + } + offset += n as u64; + + let progress = (offset as f64 / test_size_bytes as f64 * 100.0) as u32; + if progress % 20 == 0 + && offset % (test_size_bytes / 5).max(1) < CHUNK_SIZE as u64 + { + emit_log(&format!(" Verify progress: {}%", progress), &mut logs, app); + } + } + + Ok((errors == 0, errors, first_err)) + })(); + + match verify_result { + Ok((pass, errors, first_err)) => { + integrity_pass = pass; + integrity_errors = errors; + first_error_offset = first_err; + + if pass { + emit_log( + "Integrity check PASSED โ€” all bytes verified correctly", + &mut logs, + app, + ); + } else { + emit_log( + &format!( + "Integrity check FAILED โ€” {} byte errors detected (first at offset {})", + errors, + first_err.unwrap_or(0) + ), + &mut logs, + app, + ); + } + } + Err(e) => { + emit_log( + &format!("ERROR: Integrity verification failed: {}", e), + &mut logs, + app, + ); + integrity_pass = false; + } + } + + let integrity_severity = if integrity_pass { + FindingSeverity::Success + } else if integrity_errors < 100 { + FindingSeverity::Error + } else { + FindingSeverity::Critical + }; + + findings.push(ServiceFinding { + severity: integrity_severity, + title: if integrity_pass { + "Data Integrity: PASS".to_string() + } else { + format!("Data Integrity: FAIL ({} errors)", integrity_errors) + }, + description: if integrity_pass { + format!("All {} MB of written data verified byte-by-byte with zero corruption.", test_size_mb) + } else { + format!( + "{} byte-level errors detected across {} MB. First error at offset {}. This indicates flash cell failure or a counterfeit drive.", + integrity_errors, + test_size_mb, + first_error_offset.unwrap_or(0) + ) + }, + recommendation: if integrity_pass { + None + } else { + Some("This drive should NOT be used for important data. Replace it immediately โ€” data stored on this drive may become corrupt.".to_string()) + }, + data: Some(json!({ + "type": "usb_integrity", + "pass": integrity_pass, + "errorCount": integrity_errors, + "firstErrorOffset": first_error_offset, + "bytesVerified": test_size_bytes, + })), + }); + } else { + emit_log( + "Phase 4: Skipped (integrity check disabled)", + &mut logs, + app, + ); + } + + // ===================================================================== + // Phase 5: Random I/O Latency Test + // ===================================================================== + emit_log( + "Phase 5: Random I/O latency test (100 random 4KB reads)...", + &mut logs, + app, + ); + + let random_io_result = (|| -> Result<(f64, f64, f64), String> { + let mut file = fs::File::open(&test_file) + .map_err(|e| format!("Failed to open file for random I/O: {}", e))?; + let file_size = test_size_bytes; + let mut small_buf = vec![0u8; RANDOM_IO_BLOCK]; + let mut latencies: Vec = Vec::with_capacity(RANDOM_IO_ITERATIONS); + + // Simple deterministic "random" offsets using a linear congruential generator + let mut seed: u64 = 12345; + for _ in 0..RANDOM_IO_ITERATIONS { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let offset = (seed % (file_size.saturating_sub(RANDOM_IO_BLOCK as u64))).max(0); + + let io_start = Instant::now(); + file.seek(SeekFrom::Start(offset)) + .map_err(|e| format!("Seek error: {}", e))?; + file.read_exact(&mut small_buf) + .map_err(|e| format!("Random read error: {}", e))?; + let latency_ms = io_start.elapsed().as_secs_f64() * 1000.0; + latencies.push(latency_ms); + } + + let avg = latencies.iter().sum::() / latencies.len() as f64; + let min = latencies.iter().cloned().fold(f64::INFINITY, f64::min); + let max = latencies.iter().cloned().fold(0.0_f64, f64::max); + + Ok((avg, min, max)) + })(); + + match random_io_result { + Ok((avg_ms, min_ms, max_ms)) => { + emit_log( + &format!( + "Random I/O: avg {:.2}ms, min {:.2}ms, max {:.2}ms", + avg_ms, min_ms, max_ms + ), + &mut logs, + app, + ); + + let io_severity = if avg_ms > 50.0 { + FindingSeverity::Error + } else if avg_ms > 10.0 { + FindingSeverity::Warning + } else if avg_ms > 2.0 { + FindingSeverity::Info + } else { + FindingSeverity::Success + }; + + let io_description = if avg_ms > 50.0 { + "Very slow random access โ€” the drive controller may be failing or severely bottlenecked." + } else if avg_ms > 10.0 { + "Elevated random access latency. Typical for USB 2.0 or older flash drives." + } else if avg_ms > 2.0 { + "Normal random access latency for a USB flash drive." + } else { + "Excellent random access latency โ€” consistent with USB 3.0+ drives." + }; + + findings.push(ServiceFinding { + severity: io_severity, + title: format!("Random I/O: {:.2}ms avg latency", avg_ms), + description: format!( + "100 random 4KB reads โ€” avg {:.2}ms, min {:.2}ms, max {:.2}ms. {}", + avg_ms, min_ms, max_ms, io_description + ), + recommendation: if avg_ms > 50.0 { + Some("Consider replacing this drive. High latency indicates controller issues.".to_string()) + } else { + None + }, + data: Some(json!({ + "type": "usb_random_io", + "avgMs": avg_ms, + "minMs": min_ms, + "maxMs": max_ms, + "iterations": RANDOM_IO_ITERATIONS, + "blockSize": RANDOM_IO_BLOCK, + })), + }); + } + Err(e) => { + emit_log( + &format!("Warning: Random I/O test failed: {}", e), + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Warning, + title: "Random I/O Test Failed".to_string(), + description: e, + recommendation: None, + data: Some(json!({ "type": "usb_error", "error": "random_io_failed" })), + }); + } + } + + // ===================================================================== + // Phase 6: Capacity Spot-Check + // ===================================================================== + emit_log("Phase 6: Capacity verification...", &mut logs, app); + + // Re-read disk info after writing + let disks_after = Disks::new_with_refreshed_list(); + let disk_after = disks_after.list().iter().find(|d| { + d.mount_point() + .to_string_lossy() + .trim_end_matches('\\') + .eq_ignore_ascii_case(mount_point.trim_end_matches('\\')) + }); + + if let Some(disk_after) = disk_after { + let space_after = disk_after.available_space(); + let space_used_by_test = available_space.saturating_sub(space_after); + let expected_used = test_size_bytes; + + // Allow 5% tolerance + 10MB overhead for filesystem metadata + let tolerance = (expected_used as f64 * 0.05) as u64 + (10 * 1024 * 1024); + let discrepancy = if space_used_by_test > expected_used { + space_used_by_test - expected_used + } else { + expected_used - space_used_by_test + }; + + let capacity_ok = discrepancy <= tolerance; + + emit_log( + &format!( + "Capacity check: expected ~{} MB used, actual ~{} MB used (discrepancy: {} MB)", + expected_used / 1_048_576, + space_used_by_test / 1_048_576, + discrepancy / 1_048_576 + ), + &mut logs, + app, + ); + + if capacity_ok { + findings.push(ServiceFinding { + severity: FindingSeverity::Success, + title: "Capacity Verification: PASS".to_string(), + description: format!( + "Reported capacity matches actual storage. Wrote {} MB, space used: {} MB.", + expected_used / 1_048_576, + space_used_by_test / 1_048_576 + ), + recommendation: None, + data: Some(json!({ + "type": "usb_capacity", + "pass": true, + "expectedMb": expected_used / 1_048_576, + "actualUsedMb": space_used_by_test / 1_048_576, + "discrepancyMb": discrepancy / 1_048_576, + })), + }); + } else { + emit_log( + "WARNING: Significant capacity discrepancy โ€” possible fake drive!", + &mut logs, + app, + ); + findings.push(ServiceFinding { + severity: FindingSeverity::Critical, + title: "โš  FAKE DRIVE SUSPECTED".to_string(), + description: format!( + "Capacity mismatch detected! Expected ~{} MB used but only ~{} MB was actually stored. This drive may report a larger capacity than its actual storage.", + expected_used / 1_048_576, + space_used_by_test / 1_048_576 + ), + recommendation: Some("Do NOT use this drive for important data. It likely has less real storage than advertised. Consider returning it.".to_string()), + data: Some(json!({ + "type": "usb_capacity", + "pass": false, + "expectedMb": expected_used / 1_048_576, + "actualUsedMb": space_used_by_test / 1_048_576, + "discrepancyMb": discrepancy / 1_048_576, + "fakeDriveSuspected": true, + })), + }); + } + } else { + emit_log( + "Warning: Could not re-detect drive for capacity check", + &mut logs, + app, + ); + } + + // ===================================================================== + // Cleanup + // ===================================================================== + cleanup(&temp_dir, &mut logs, app); + + // ===================================================================== + // Overall Summary + // ===================================================================== + let total_duration = start.elapsed(); + let has_critical = findings + .iter() + .any(|f| matches!(f.severity, FindingSeverity::Critical)); + let has_errors = findings + .iter() + .any(|f| matches!(f.severity, FindingSeverity::Error)); + let has_warnings = findings + .iter() + .any(|f| matches!(f.severity, FindingSeverity::Warning)); + + let overall_status = if has_critical { + "FAIL" + } else if has_errors { + "ISSUES DETECTED" + } else if has_warnings { + "PASS WITH WARNINGS" + } else { + "PASS" + }; + + let overall_severity = if has_critical { + FindingSeverity::Critical + } else if has_errors { + FindingSeverity::Error + } else if has_warnings { + FindingSeverity::Warning + } else { + FindingSeverity::Success + }; + + emit_log( + &format!( + "USB Stability Test complete: {} (took {:.1}s)", + overall_status, + total_duration.as_secs_f64() + ), + &mut logs, + app, + ); + + // Insert summary finding at the beginning + findings.insert( + 0, + ServiceFinding { + severity: overall_severity, + title: format!("USB Stability Test: {}", overall_status), + description: format!( + "Tested {} ({}) โ€” {} on {}. Write: {:.1} MB/s, Read: {:.1} MB/s. Integrity: {}.", + mount_point, + volume_label, + format_bytes(total_space), + fs_type, + write_speed_mbps, + read_speed_mbps, + if !verify_integrity { + "skipped" + } else if integrity_pass { + "PASS" + } else { + "FAIL" + } + ), + recommendation: None, + data: Some(json!({ + "type": "usb_summary", + "drivePath": mount_point, + "volumeLabel": volume_label, + "fileSystem": fs_type, + "totalSpaceBytes": total_space, + "availableSpaceBytes": available_space, + "testSizeMb": test_size_mb, + "intensity": intensity, + "writeSpeedMbps": write_speed_mbps, + "readSpeedMbps": read_speed_mbps, + "writeDurationSecs": write_duration.as_secs_f64(), + "readDurationSecs": read_duration.as_secs_f64(), + "integrityPass": integrity_pass, + "integrityErrors": integrity_errors, + "integrityChecked": verify_integrity, + "overallStatus": overall_status, + "totalDurationSecs": total_duration.as_secs_f64(), + })), + }, + ); + + ServiceResult { + service_id: service_id.to_string(), + success: !has_critical && !has_errors, + error: None, + duration_ms: total_duration.as_millis() as u64, + findings, + logs, + agent_analysis: None, + } + } +} + +// ============================================================================= +// Helper Types & Functions +// ============================================================================= + +struct SpeedRating { + severity: FindingSeverity, + label: String, + description: String, + recommendation: Option, +} + +fn rate_speed(speed_mbps: f64, is_read: bool) -> SpeedRating { + let direction = if is_read { "read" } else { "write" }; + + if speed_mbps >= 100.0 { + SpeedRating { + severity: FindingSeverity::Success, + label: "Excellent".to_string(), + description: format!("Excellent {} speed โ€” consistent with USB 3.0+.", direction), + recommendation: None, + } + } else if speed_mbps >= 50.0 { + SpeedRating { + severity: FindingSeverity::Success, + label: "Good".to_string(), + description: format!("Good {} speed โ€” typical for USB 3.0 drives.", direction), + recommendation: None, + } + } else if speed_mbps >= 20.0 { + SpeedRating { + severity: FindingSeverity::Info, + label: "Average".to_string(), + description: format!( + "Average {} speed โ€” possibly USB 2.0 or a slower USB 3.0 drive.", + direction + ), + recommendation: Some( + "Try a USB 3.0 port if available for better performance.".to_string(), + ), + } + } else if speed_mbps >= 5.0 { + SpeedRating { + severity: FindingSeverity::Warning, + label: "Slow".to_string(), + description: format!("Slow {} speed โ€” typical of USB 2.0 connections or aging flash memory.", direction), + recommendation: Some("Ensure the drive is in a USB 3.0 port. If already in USB 3.0, the drive's flash memory may be worn.".to_string()), + } + } else { + SpeedRating { + severity: FindingSeverity::Error, + label: "Very Slow".to_string(), + description: format!("Very slow {} speed โ€” indicates a failing drive or severely bottlenecked connection.", direction), + recommendation: Some("This drive may be failing. Consider replacing it.".to_string()), + } + } +} + +fn format_bytes(bytes: u64) -> String { + let gb = bytes as f64 / 1_073_741_824.0; + if gb >= 1000.0 { + format!("{:.1} TB", gb / 1024.0) + } else { + format!("{:.1} GB", gb) + } +} diff --git a/src-tauri/src/services/whynotwin11.rs b/src-tauri/src/services/whynotwin11.rs index 53f879b..821efe9 100644 --- a/src-tauri/src/services/whynotwin11.rs +++ b/src-tauri/src/services/whynotwin11.rs @@ -34,6 +34,8 @@ impl Service for WhyNotWin11Service { required_programs: vec!["whynotwin11".to_string()], options: vec![], icon: "monitor-check".to_string(), + exclusive_resources: vec![], + dependencies: vec![], } } @@ -71,6 +73,7 @@ impl Service for WhyNotWin11Service { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } Err(e) => { @@ -81,6 +84,7 @@ impl Service for WhyNotWin11Service { duration_ms: start.elapsed().as_millis() as u64, findings: vec![], logs, + agent_analysis: None, }; } }; @@ -249,6 +253,7 @@ impl Service for WhyNotWin11Service { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/windows_update.rs b/src-tauri/src/services/windows_update.rs index 8d884ba..cba35d2 100644 --- a/src-tauri/src/services/windows_update.rs +++ b/src-tauri/src/services/windows_update.rs @@ -56,6 +56,8 @@ impl Service for WindowsUpdateService { }, ], icon: "cloud-download".to_string(), + exclusive_resources: vec![], + dependencies: vec![], } } @@ -123,6 +125,7 @@ impl Service for WindowsUpdateService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } @@ -168,6 +171,7 @@ impl Service for WindowsUpdateService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, }; } }; @@ -329,6 +333,7 @@ impl Service for WindowsUpdateService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/services/winsat.rs b/src-tauri/src/services/winsat.rs index 26819cb..2b5052c 100644 --- a/src-tauri/src/services/winsat.rs +++ b/src-tauri/src/services/winsat.rs @@ -42,6 +42,8 @@ impl Service for WinsatService { description: Some("Drive letter to benchmark (e.g., C, D)".to_string()), }], icon: "gauge".to_string(), + exclusive_resources: vec!["cpu-stress".to_string()], + dependencies: vec![], } } @@ -285,6 +287,7 @@ impl Service for WinsatService { duration_ms: start.elapsed().as_millis() as u64, findings, logs, + agent_analysis: None, } } } diff --git a/src-tauri/src/types/agent.rs b/src-tauri/src/types/agent.rs new file mode 100644 index 0000000..5d4ed4b --- /dev/null +++ b/src-tauri/src/types/agent.rs @@ -0,0 +1,874 @@ +//! Agent system types +//! +//! Types for the agentic AI system including settings, memory, and command execution. + +use serde::{Deserialize, Serialize}; + +// ============================================================================= +// Provider Types +// ============================================================================= + +/// Supported AI providers +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum AgentProvider { + OpenAI, + Anthropic, + #[serde(rename = "xai")] + XAI, + Google, + Mistral, + DeepSeek, + Groq, + OpenRouter, + Ollama, + Custom, +} + +impl Default for AgentProvider { + fn default() -> Self { + Self::OpenAI + } +} + +/// Per-provider API key storage +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProviderApiKeys { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openai: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anthropic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub xai: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub google: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mistral: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deepseek: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openrouter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, +} + +// ============================================================================= +// Command Approval Types +// ============================================================================= + +/// Command approval mode +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ApprovalMode { + Always, + Whitelist, + Yolo, +} + +impl Default for ApprovalMode { + fn default() -> Self { + Self::Always + } +} + +/// Status of a pending command +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CommandStatus { + Pending, + Approved, + Rejected, + Executed, + Failed, +} + +impl Default for CommandStatus { + fn default() -> Self { + Self::Pending + } +} + +/// A command awaiting user approval +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingCommand { + pub id: String, + pub command: String, + pub reason: String, + pub created_at: String, + pub status: CommandStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +// ============================================================================= +// Search Types +// ============================================================================= + +/// Search provider options +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SearchProvider { + Tavily, + Searxng, + None, +} + +impl Default for SearchProvider { + fn default() -> Self { + Self::None + } +} + +/// Search result from web search +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + pub title: String, + pub url: String, + pub snippet: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub score: Option, +} + +// ============================================================================= +// Memory Types +// ============================================================================= + +/// Memory scope determines portability across machines +/// - Global: Portable knowledge that works on any machine (solutions, knowledge, behaviors) +/// - Machine: Specific to the current machine (system state, local context) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum MemoryScope { + /// Portable across all machines - for solutions, knowledge, technician preferences + #[default] + Global, + /// Specific to the current machine - for system state, local diagnostics + Machine, +} + +impl MemoryScope { + /// Convert from string to MemoryScope + pub fn from_str(s: &str) -> Self { + match s { + "machine" => MemoryScope::Machine, + _ => MemoryScope::Global, // Default to global for portability + } + } + + /// Convert to string + pub fn as_str(&self) -> &'static str { + match self { + MemoryScope::Global => "global", + MemoryScope::Machine => "machine", + } + } + + /// Get the default scope for a memory type + /// - System memories are machine-specific + /// - Solutions, knowledge, behaviors, instructions, facts are global/portable + /// - Conversations and summaries default to machine (can be overridden) + pub fn default_for_type(memory_type: &MemoryType) -> Self { + match memory_type { + MemoryType::System => MemoryScope::Machine, + MemoryType::Conversation => MemoryScope::Machine, + MemoryType::Summary => MemoryScope::Machine, + // Solutions, knowledge, behaviors, instructions, facts are portable + _ => MemoryScope::Global, + } + } +} + +/// Types of memories the agent can store +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum MemoryType { + /// User-provided facts and information + Fact, + /// Successful problem solutions + Solution, + /// Conversation fragments and context + Conversation, + /// Behavioral instructions for the agent + Instruction, + /// Agent behavior adjustments + Behavior, + /// Knowledge base documents (RAG) + Knowledge, + /// Conversation summaries for context compression + Summary, + /// System state snapshots (computer info the agent learns) + System, +} + +impl Default for MemoryType { + fn default() -> Self { + Self::Fact + } +} + +impl MemoryType { + /// Convert from string to MemoryType + pub fn from_str(s: &str) -> Self { + match s { + "fact" => MemoryType::Fact, + "solution" => MemoryType::Solution, + "conversation" => MemoryType::Conversation, + "instruction" => MemoryType::Instruction, + "behavior" => MemoryType::Behavior, + "knowledge" => MemoryType::Knowledge, + "summary" => MemoryType::Summary, + "system" => MemoryType::System, + _ => MemoryType::Fact, + } + } + + /// Convert to string + pub fn as_str(&self) -> &'static str { + match self { + MemoryType::Fact => "fact", + MemoryType::Solution => "solution", + MemoryType::Conversation => "conversation", + MemoryType::Instruction => "instruction", + MemoryType::Behavior => "behavior", + MemoryType::Knowledge => "knowledge", + MemoryType::Summary => "summary", + MemoryType::System => "system", + } + } +} + +/// A memory entry stored in the database +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Memory { + pub id: String, + #[serde(rename = "type")] + pub memory_type: MemoryType, + pub content: String, + pub metadata: serde_json::Value, + pub created_at: String, + pub updated_at: String, + /// Importance score (0-100) for memory prioritization + #[serde(default)] + pub importance: i32, + /// Number of times this memory has been accessed + #[serde(default)] + pub access_count: i32, + /// Last time this memory was accessed + #[serde(skip_serializing_if = "Option::is_none")] + pub last_accessed: Option, + /// Source conversation ID for linking memories to conversations + #[serde(skip_serializing_if = "Option::is_none")] + pub source_conversation_id: Option, + /// Memory scope: global (portable) or machine (local) + #[serde(default)] + pub scope: MemoryScope, + /// Machine identifier for machine-scoped memories + /// Only set when scope is Machine, used for filtering + #[serde(skip_serializing_if = "Option::is_none")] + pub machine_id: Option, +} + +/// Memory search result with similarity score +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemorySearchResult { + #[serde(flatten)] + pub memory: Memory, + pub similarity: f64, +} + +/// Memory statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryStats { + pub total_count: i64, + pub by_type: std::collections::HashMap, + pub total_size_bytes: i64, +} + +// ============================================================================= +// Conversation Types +// ============================================================================= + +/// A saved agent conversation +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Conversation { + pub id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, +} + +/// A message within a conversation (serialized CoreMessage content) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationMessage { + pub id: String, + pub conversation_id: String, + /// Role: "user", "assistant", or "tool" + pub role: String, + /// JSON-serialized message content + pub content: String, + pub created_at: String, +} + +/// Conversation with its messages +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationWithMessages { + #[serde(flatten)] + pub conversation: Conversation, + pub messages: Vec, +} + +// ============================================================================= +// Agent Settings +// ============================================================================= + +/// Supported embedding providers +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum EmbeddingProvider { + #[default] + OpenAI, + Google, + Mistral, + Cohere, + Ollama, +} + +/// Agent configuration settings +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSettings { + /// AI provider to use + #[serde(default)] + pub provider: AgentProvider, + + /// Model name/identifier + #[serde(default = "default_model")] + pub model: String, + + /// Per-provider API key storage + #[serde(default)] + pub api_keys: ProviderApiKeys, + + /// Base URL for custom/Ollama providers + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_url: Option, + + /// Command approval mode + #[serde(default)] + pub approval_mode: ApprovalMode, + + /// Whitelisted command patterns (regex) + #[serde(default = "default_whitelist")] + pub whitelisted_commands: Vec, + + /// Search provider to use + #[serde(default)] + pub search_provider: SearchProvider, + + /// Tavily API key + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tavily_api_key: Option, + + /// SearXNG instance URL + #[serde(default, skip_serializing_if = "Option::is_none")] + pub searxng_url: Option, + + /// Whether memory is enabled + #[serde(default = "default_memory_enabled")] + pub memory_enabled: bool, + + /// Embedding provider to use + #[serde(default)] + pub embedding_provider: EmbeddingProvider, + + /// Embedding model to use + #[serde(default = "default_embedding_model")] + pub embedding_model: String, + + /// Cohere API key (if using Cohere for embeddings) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cohere_api_key: Option, + + /// Custom system prompt + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + + // ========================================================================== + // Agent Zero-like Memory Features + // ========================================================================== + /// Auto-save successful solutions to memory + #[serde(default = "default_true")] + pub auto_memory_solutions: bool, + + /// Automatically extract facts from conversations + #[serde(default)] + pub auto_extract_facts: bool, + + /// Enable conversation summarization for context compression + #[serde(default)] + pub context_compression_enabled: bool, + + /// Message count before compressing conversation context + #[serde(default = "default_compression_threshold")] + pub context_compression_threshold: i32, + + /// Automatically inject relevant knowledge base entries on each message + #[serde(default = "default_true")] + pub auto_rag_enabled: bool, + + /// Number of days to retain memories (0 = forever) + #[serde(default)] + pub memory_retention_days: i32, + + /// Maximum number of memories to inject into context + #[serde(default = "default_max_context_memories")] + pub max_context_memories: i32, + + // ========================================================================== + // MCP Server Settings + // ========================================================================== + /// Whether the MCP HTTP server is enabled + #[serde(default)] + pub mcp_server_enabled: bool, + + /// API key for MCP server authentication (auto-generated) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_api_key: Option, + + /// Port for the MCP HTTP server + #[serde(default = "default_mcp_port")] + pub mcp_port: u16, + + // ========================================================================== + // MCP Client Settings (connecting to external servers) + // ========================================================================== + /// External MCP servers the agent can connect to for additional tools + #[serde(default)] + pub mcp_servers: Vec, +} + +/// Transport type for MCP server connections +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum MCPTransportType { + #[default] + Sse, + Http, +} + +/// Configuration for an external MCP server the agent connects to +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MCPServerConfig { + /// Unique identifier + pub id: String, + /// Display name + pub name: String, + /// Server URL + pub url: String, + /// Transport type (sse or http) + #[serde(default)] + pub transport_type: MCPTransportType, + /// Whether this server is enabled + #[serde(default)] + pub enabled: bool, + /// Optional API key for authentication + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key: Option, + /// Optional custom headers + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option>, +} + +fn default_model() -> String { + "gpt-4o-mini".to_string() +} + +fn default_whitelist() -> Vec { + vec![ + "^ipconfig".to_string(), + "^ping ".to_string(), + "^systeminfo$".to_string(), + "^tasklist$".to_string(), + "^hostname$".to_string(), + "^whoami$".to_string(), + ] +} + +fn default_memory_enabled() -> bool { + true +} + +fn default_embedding_model() -> String { + "text-embedding-3-small".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_compression_threshold() -> i32 { + 20 +} + +fn default_max_context_memories() -> i32 { + 5 +} + +fn default_mcp_port() -> u16 { + 8377 +} + +impl Default for AgentSettings { + fn default() -> Self { + Self { + provider: AgentProvider::default(), + model: default_model(), + api_keys: ProviderApiKeys::default(), + base_url: None, + approval_mode: ApprovalMode::default(), + whitelisted_commands: default_whitelist(), + search_provider: SearchProvider::default(), + tavily_api_key: None, + searxng_url: None, + memory_enabled: default_memory_enabled(), + embedding_provider: EmbeddingProvider::default(), + embedding_model: default_embedding_model(), + cohere_api_key: None, + system_prompt: None, + // Agent Zero-like Memory Features + auto_memory_solutions: true, + auto_extract_facts: false, + context_compression_enabled: false, + context_compression_threshold: default_compression_threshold(), + auto_rag_enabled: true, + memory_retention_days: 0, + max_context_memories: default_max_context_memories(), + // MCP Server Settings + mcp_server_enabled: false, + mcp_api_key: None, + mcp_port: default_mcp_port(), + // MCP Client Settings + mcp_servers: Vec::new(), + } + } +} + +// ============================================================================= +// Tool Execution Types +// ============================================================================= + +/// Tool execution response from backend +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolExecutionResponse { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default)] + pub requires_approval: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_command_id: Option, +} + +/// Command execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandExecutionResult { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +// ============================================================================= +// File Attachment Types +// ============================================================================= + +/// Categories of files for specialized handling +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FileCategory { + Text, + Code, + Document, + Image, + Media, + Binary, +} + +impl FileCategory { + /// Get category from MIME type + pub fn from_mime_type(mime_type: &str) -> Self { + if mime_type.starts_with("text/") { + return Self::Text; + } + if mime_type.starts_with("image/") { + return Self::Image; + } + if mime_type.starts_with("audio/") || mime_type.starts_with("video/") { + return Self::Media; + } + + let code_types = [ + "application/javascript", + "application/json", + "application/xml", + "application/x-python-code", + "application/x-sh", + ]; + if code_types.contains(&mime_type) || mime_type.contains("script") { + return Self::Code; + } + + let doc_types = [ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats", + ]; + if doc_types.iter().any(|t| mime_type.contains(t)) { + return Self::Document; + } + + Self::Binary + } + + /// Get category from file extension + pub fn from_extension(filename: &str) -> Self { + let ext = filename.split('.').last().unwrap_or("").to_lowercase(); + + let code_exts = [ + "js", "ts", "jsx", "tsx", "py", "rs", "java", "cpp", "c", "h", "hpp", "go", "rb", + "php", "swift", "kt", "scala", "r", "m", "mm", "cs", "vb", "fs", "hs", "lua", "pl", + "sh", "bash", "zsh", "fish", "ps1", "cmd", "bat", "sql", "html", "css", "scss", "sass", + "less", "xml", "yaml", "yml", "toml", "ini", "conf", "config", "json", "md", + "markdown", + ]; + if code_exts.contains(&ext.as_str()) { + return Self::Code; + } + + let image_exts = [ + "png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "tiff", "raw", + ]; + if image_exts.contains(&ext.as_str()) { + return Self::Image; + } + + let media_exts = [ + "mp3", "mp4", "wav", "avi", "mov", "mkv", "flv", "wmv", "webm", "ogg", "ogv", + ]; + if media_exts.contains(&ext.as_str()) { + return Self::Media; + } + + let doc_exts = [ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "txt", + ]; + if doc_exts.contains(&ext.as_str()) { + return Self::Document; + } + + Self::Binary + } + + /// Check if this category should have content auto-extracted + pub fn should_auto_extract(&self) -> bool { + matches!(self, Self::Text | Self::Code) + } +} + +/// Source of a file attachment +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum FileSource { + Upload, + Generated, + Filesystem, +} + +/// Unified file attachment metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileAttachment { + pub id: String, + pub source: FileSource, + pub original_name: String, + pub stored_name: String, + pub mime_type: String, + pub category: FileCategory, + pub size: u64, + pub stored_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line_count: Option, + pub checksum: String, + pub uploaded_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(flatten)] + pub metadata: FileAttachmentMetadata, +} + +/// Source-specific metadata for file attachments +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileAttachmentMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub upload_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generation_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filesystem_metadata: Option, +} + +impl Default for FileAttachmentMetadata { + fn default() -> Self { + Self { + upload_metadata: None, + generation_metadata: None, + filesystem_metadata: None, + } + } +} + +/// Metadata for user-uploaded files +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadMetadata { + pub uploaded_by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub original_path: Option, + pub auto_extracted: bool, +} + +/// Metadata for agent-generated files +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationMetadata { + pub generated_by: String, + pub description: String, + pub tool_call_id: String, + pub approved: bool, +} + +/// Metadata for filesystem-referenced files +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FilesystemMetadata { + pub original_path: String, + pub accessed_at: String, + pub auto_read: bool, +} + +/// File upload request from frontend +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileUploadRequest { + pub file_name: String, + pub mime_type: String, + pub size: u64, + pub content_base64: String, +} + +/// File chunk upload request for large files +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileChunkRequest { + pub upload_id: String, + pub chunk_index: u32, + pub total_chunks: u32, + pub content_base64: String, +} + +/// Status of a chunked upload +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChunkUploadStatus { + pub upload_id: String, + pub chunks_received: Vec, + pub chunks_total: u32, + pub bytes_received: u64, + pub bytes_total: u64, + pub complete: bool, +} + +/// File generation request from agent +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileGenerationRequest { + pub filename: String, + pub content: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +/// Result of validating a filesystem path +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PathValidationResult { + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sanitized_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub within_sandbox: bool, +} + +/// Request to read a file from the filesystem +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FilesystemReadRequest { + pub path: String, + #[serde(default)] + pub auto_extract: bool, + #[serde(default)] + pub max_size: Option, +} + +/// File size limits (in bytes) +pub const FILE_SIZE_SMALL: u64 = 10 * 1024 * 1024; // 10 MB +pub const FILE_SIZE_LARGE: u64 = 100 * 1024 * 1024; // 100 MB +pub const FILE_SIZE_HUGE: u64 = 1024 * 1024 * 1024; // 1 GB +pub const CHUNK_SIZE: usize = 1024 * 1024; // 1 MB +pub const MAX_CONTENT_EXTRACTION_SIZE: usize = 100 * 1024; // 100 KB + +/// Helper to format file size for display +pub fn format_file_size(bytes: u64) -> String { + if bytes == 0 { + return "0 B".to_string(); + } + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let k = 1024_f64; + let i = (bytes as f64).log(k).floor() as usize; + let size = bytes as f64 / k.powi(i as i32); + format!("{:.2} {}", size, UNITS[i.min(UNITS.len() - 1)]) +} + +/// Helper to compute SHA-256 checksum +pub fn compute_checksum(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(data); + hex::encode(hasher.finalize()) +} diff --git a/src-tauri/src/types/mod.rs b/src-tauri/src/types/mod.rs index 6f2db9c..be266c6 100644 --- a/src-tauri/src/types/mod.rs +++ b/src-tauri/src/types/mod.rs @@ -2,6 +2,7 @@ //! //! This module contains all the data structures used throughout the application. +mod agent; mod program; mod required_program; mod script; @@ -10,6 +11,7 @@ mod settings; mod system_info; mod time_tracking; +pub use agent::*; pub use program::*; pub use required_program::*; pub use script::*; diff --git a/src-tauri/src/types/service.rs b/src-tauri/src/types/service.rs index 2d23f58..88010d1 100644 --- a/src-tauri/src/types/service.rs +++ b/src-tauri/src/types/service.rs @@ -74,6 +74,15 @@ pub struct ServiceDefinition { pub options: Vec, /// Icon name (lucide icon identifier) pub icon: String, + /// Resource tags that conflict with other services sharing the same tag. + /// Services with overlapping exclusive_resources will not run concurrently. + /// Empty vec means the service can run in parallel with anything. + #[serde(default)] + pub exclusive_resources: Vec, + /// Service IDs that must complete before this service can run. + /// Empty vec means no dependencies. + #[serde(default)] + pub dependencies: Vec, } // ============================================================================= @@ -178,6 +187,9 @@ pub struct ServiceResult { pub findings: Vec, /// Log output from the service pub logs: Vec, + /// Agent-generated analysis for this service result + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_analysis: Option, } /// Status of a service run @@ -188,6 +200,8 @@ pub enum ServiceRunStatus { Pending, /// Currently running Running, + /// Paused (e.g., by agent for intervention) + Paused, /// Completed successfully Completed, /// Failed with error @@ -216,14 +230,29 @@ pub struct ServiceReport { pub queue: Vec, /// Results for each service (keyed by service_id) pub results: Vec, - /// Index of currently running service (for progress) + /// Index of currently running service (for progress, sequential mode) pub current_service_index: Option, + /// Indices of currently running services (parallel mode) + #[serde(default)] + pub current_service_indices: Vec, + /// Whether this run used parallel (experimental) execution + #[serde(default)] + pub parallel_mode: bool, /// Technician who performed the service (business mode) #[serde(default, skip_serializing_if = "Option::is_none")] pub technician_name: Option, /// Customer name (business mode) #[serde(default, skip_serializing_if = "Option::is_none")] pub customer_name: Option, + /// Whether this run was initiated by the AI agent + #[serde(default)] + pub agent_initiated: bool, + /// Agent-generated executive summary + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_summary: Option, + /// Agent-computed health score (0-100) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub health_score: Option, } // ============================================================================= @@ -236,6 +265,8 @@ pub struct ServiceReport { pub struct ServiceRunState { /// Whether a service run is currently active pub is_running: bool, + /// Whether the run is currently paused (agent intervention) + pub is_paused: bool, /// Current report being generated #[serde(skip_serializing_if = "Option::is_none")] pub current_report: Option, @@ -245,7 +276,49 @@ impl Default for ServiceRunState { fn default() -> Self { Self { is_running: false, + is_paused: false, current_report: None, } } } + +// ============================================================================= +// Report Statistics (computed by agent tooling) +// ============================================================================= + +/// Computed statistics for a service report +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportStatistics { + /// Total number of services run + pub total_services: usize, + /// Number of services that succeeded + pub passed: usize, + /// Number of services that failed + pub failed: usize, + /// Total duration in milliseconds + pub total_duration_ms: u64, + /// Average duration per service in milliseconds + pub avg_duration_ms: u64, + /// Slowest service ID and duration + pub slowest_service: Option<(String, u64)>, + /// Fastest service ID and duration + pub fastest_service: Option<(String, u64)>, + /// Finding counts by severity + pub findings_by_severity: FindingSeverityCounts, + /// Total number of findings + pub total_findings: usize, + /// Computed health score (0-100) + pub health_score: u8, +} + +/// Counts of findings grouped by severity +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindingSeverityCounts { + pub info: usize, + pub success: usize, + pub warning: usize, + pub error: usize, + pub critical: usize, +} diff --git a/src-tauri/src/types/settings.rs b/src-tauri/src/types/settings.rs index f62de74..d1dd487 100644 --- a/src-tauri/src/types/settings.rs +++ b/src-tauri/src/types/settings.rs @@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize}; +use crate::types::agent::AgentSettings; use crate::types::service::ServicePreset; /// Current settings schema version for migration support -pub const SETTINGS_VERSION: &str = "0.6.0"; +pub const SETTINGS_VERSION: &str = "0.7.0"; /// Appearance-related settings #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,7 +26,7 @@ pub struct AppearanceSettings { } fn default_color_scheme() -> String { - String::from("default") + String::from("forge") } fn default_animations_enabled() -> bool { @@ -203,6 +204,9 @@ pub struct AppSettings { /// Custom service presets #[serde(default)] pub presets: PresetsSettings, + /// Agent AI settings + #[serde(default)] + pub agent: AgentSettings, } impl Default for AppSettings { @@ -216,6 +220,7 @@ impl Default for AppSettings { programs: ProgramsSettings::default(), technician_tabs: TechnicianTabsSettings::default(), presets: PresetsSettings::default(), + agent: AgentSettings::default(), } } } diff --git a/src-tauri/src/types/system_info.rs b/src-tauri/src/types/system_info.rs index a79e51a..47ed172 100644 --- a/src-tauri/src/types/system_info.rs +++ b/src-tauri/src/types/system_info.rs @@ -199,6 +199,41 @@ pub struct ProcessInfo { pub memory_bytes: u64, } +/// BIOS information +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BiosInfo { + pub manufacturer: Option, + pub version: Option, + pub release_date: Option, + pub serial_number: Option, +} + +/// System product information (chassis/system) +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemProductInfo { + pub vendor: Option, + pub model: Option, + pub serial_number: Option, + pub uuid: Option, +} + +/// RAM slot/DIMM information +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RamSlotInfo { + pub bank_label: Option, + pub device_locator: Option, + pub manufacturer: Option, + pub part_number: Option, + pub serial_number: Option, + pub speed_mhz: Option, + pub capacity_bytes: Option, + pub form_factor: Option, + pub memory_type: Option, +} + /// Complete system information response #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -222,4 +257,16 @@ pub struct SystemInfo { pub top_processes: Vec, pub uptime_seconds: u64, pub boot_time: u64, + /// BIOS details + pub bios: Option, + /// System product/chassis details + pub system_product: Option, + /// RAM slot details + pub ram_slots: Vec, + /// CPU L2 cache in KB + pub cpu_l2_cache_kb: Option, + /// CPU L3 cache in KB + pub cpu_l3_cache_kb: Option, + /// CPU socket designation + pub cpu_socket: Option, } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 033ad86..a52e475 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,12 +1,12 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "rustservice", + "productName": "RustService", "version": "0.1.0", "identifier": "com.rustservice.app", "build": { - "beforeDevCommand": "pnpm dev", + "beforeDevCommand": "bun dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "pnpm build", + "beforeBuildCommand": "bun run build", "frontendDist": "../dist" }, "app": { diff --git a/src/App.tsx b/src/App.tsx index 876ab45..ec1c53c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { Rocket, Network, Skull, + Bot, // Icon picker icons Folder, Database, @@ -51,9 +52,13 @@ import { import { ThemeProvider } from '@/components/theme-provider'; import { SettingsProvider, useSettings } from '@/components/settings-context'; import { AnimationProvider, useAnimation, motion, AnimatePresence, tabContentVariants } from '@/components/animation-context'; +import { ServiceRunProvider } from '@/components/service-run-context'; +import { FloatingServiceStatus } from '@/components/floating-service-status'; import { Titlebar } from '@/components/titlebar'; import { IframeTabContent } from '@/components/IframeTabContent'; +import { ErrorBoundary } from '@/components/error-boundary'; import { + AgentPage, ServicePage, SystemInfoPage, ComponentTestPage, @@ -142,6 +147,7 @@ function TechnicianTabIcon({ tab, useFavicons }: { tab: TechnicianTab; useFavico * Primary tabs shown directly in the tab bar */ const PRIMARY_TABS = [ + { id: 'agent', label: 'Agent', icon: Bot, component: AgentPage }, { id: 'service', label: 'Service', icon: Wrench, component: ServicePage }, { id: 'system-info', label: 'System Info', icon: Monitor, component: SystemInfoPage }, { id: 'component-test', label: 'Component Test', icon: TestTube, component: ComponentTestPage }, @@ -357,7 +363,9 @@ function AppContent() { className="absolute inset-0 data-[state=active]:flex data-[state=active]:flex-col m-0" > - + t.id === id)?.label}> + + ) @@ -378,6 +386,9 @@ function AppContent() { })} + + {/* Floating service status pill - visible when services run on another tab */} + ); } @@ -402,7 +413,11 @@ function App() { - + + + + + diff --git a/src/components/agent/AgentActivityItem.tsx b/src/components/agent/AgentActivityItem.tsx new file mode 100644 index 0000000..ef63518 --- /dev/null +++ b/src/components/agent/AgentActivityItem.tsx @@ -0,0 +1,669 @@ +/** + * Agent Activity Item Component + * + * Displays individual agent actions in the Claude/Cursor style. + * Each activity type has its own icon and format. + * Uses CSS variable theme classes for consistent styling. + */ + +import { useState } from 'react'; +import { + Folder, + Search, + FileText, + Terminal, + FileEdit, + Globe, + Cpu, + BookOpen, + FolderInput, + Copy, + Check, + X, + Loader2, + AlertCircle, + Play, + Plug, + FilePlus, + Paperclip, + Download, + ChevronDown, + ChevronRight, + Package, + Pause, + RotateCcw, + XCircle, + ClipboardList, + FileBarChart, + PenLine, + FileOutput, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { FileReference } from './FileReference'; +import { TerminalOutputBlock } from './TerminalOutputBlock'; +import type { AgentActivity, ActivityStatus } from '@/types/agent-activity'; + +interface AgentActivityItemProps { + activity: AgentActivity; + onApprove?: (activityId: string) => void; + onReject?: (activityId: string) => void; +} + +/** + * Get the icon and label for an activity type + */ +function getActivityConfig(type: AgentActivity['type'], status: ActivityStatus) { + const baseConfig = (() => { + switch (type) { + case 'analyzed_directory': + return { Icon: Folder, label: 'Analyzed', color: 'text-chart-4' }; + case 'searched': + return { Icon: Search, label: 'Searched', color: 'text-chart-1' }; + case 'analyzed_file': + return { Icon: FileText, label: 'Analyzed', color: 'text-muted-foreground' }; + case 'ran_command': + return { Icon: Terminal, label: 'Ran', color: 'text-chart-2' }; + case 'read_file': + return { Icon: BookOpen, label: 'Read', color: 'text-primary' }; + case 'edit_file': + return { Icon: FileEdit, label: 'Edit', color: 'text-chart-5' }; + case 'write_file': + return { Icon: FileEdit, label: 'Write to', color: 'text-chart-5' }; + case 'move_file': + return { Icon: FolderInput, label: 'Move', color: 'text-chart-4' }; + case 'copy_file': + return { Icon: Copy, label: 'Copy', color: 'text-primary' }; + case 'list_dir': + return { Icon: Folder, label: 'Listed', color: 'text-chart-4' }; + case 'list_programs': + return { Icon: Package, label: 'Programs', color: 'text-chart-4' }; + case 'find_exe': + return { Icon: Search, label: 'Found exe', color: 'text-primary' }; + case 'web_search': + return { Icon: Globe, label: 'Searched web', color: 'text-chart-5' }; + case 'get_system_info': + return { Icon: Cpu, label: 'System info', color: 'text-primary' }; + case 'mcp_tool': + return { Icon: Plug, label: 'MCP tool', color: 'text-chart-1' }; + case 'generate_file': + return { Icon: FilePlus, label: 'Generated', color: 'text-chart-5' }; + case 'attach_files': + return { Icon: Paperclip, label: 'Attached', color: 'text-primary' }; + // Service activity types + case 'service_queue_started': + return { Icon: Play, label: 'Service Queue', color: 'text-chart-2' }; + case 'service_paused': + return { Icon: Pause, label: 'Paused', color: 'text-chart-4' }; + case 'service_resumed': + return { Icon: RotateCcw, label: 'Resumed', color: 'text-chart-2' }; + case 'service_cancelled': + return { Icon: XCircle, label: 'Cancelled', color: 'text-destructive' }; + case 'service_query': + return { Icon: ClipboardList, label: 'Service Query', color: 'text-chart-1' }; + case 'service_report': + return { Icon: FileBarChart, label: 'Report', color: 'text-chart-1' }; + case 'service_edit': + return { Icon: PenLine, label: 'Edit Report', color: 'text-chart-5' }; + case 'service_pdf': + return { Icon: FileOutput, label: 'PDF Report', color: 'text-chart-5' }; + default: + return { Icon: FileText, label: 'Action', color: 'text-muted-foreground' }; + } + })(); + + // Override icon based on status + if (status === 'running') { + return { ...baseConfig, Icon: Loader2, iconClass: 'animate-spin' }; + } + if (status === 'error') { + return { ...baseConfig, Icon: X, color: 'text-destructive' }; + } + if (status === 'success') { + return { ...baseConfig, Icon: Check, color: 'text-chart-2' }; + } + + return baseConfig; +} + +/** + * Status indicator component with expandable text for long content + */ +function StatusIndicator({ status, output, error, expanded, onToggle }: { + status: ActivityStatus; + output?: string; + error?: string; + expanded?: boolean; + onToggle?: () => void; +}) { + if (status === 'running') { + return ( + + + Running... + + ); + } + if (status === 'pending_approval') { + return ( + + + Approval Required + + ); + } + if (status === 'error') { + const text = error || output || 'Failed'; + const isLong = text.length > 80; + return ( + + {text} + + ); + } + if (status === 'success' && output) { + const isLong = output.length > 80; + return ( + + {output} + + ); + } + return null; +} + +/** + * Expandable diff preview for edit_file operations + */ +function EditFilePreview({ oldString, newString }: { oldString?: string; newString?: string }) { + const [showChanges, setShowChanges] = useState(false); + + if (!oldString && !newString) return null; + + return ( +
+ + {showChanges && ( +
+ {oldString && ( +
+
- Remove:
+
+                {oldString}
+              
+
+ )} + {newString && ( +
+
+ Add:
+
+                {newString}
+              
+
+ )} +
+ )} +
+ ); +} + +/** + * Service activity block (for service_queue_started, service_paused, service_resumed, service_cancelled) + */ +function ServiceActivityBlock({ + activity, + onApprove, + onReject, +}: { + activity: AgentActivity; + onApprove?: () => void; + onReject?: () => void; +}) { + const config = getActivityConfig(activity.type, activity.status); + const isPending = activity.status === 'pending_approval'; + const isRunning = activity.status === 'running'; + const isError = activity.status === 'error'; + const isSuccess = activity.status === 'success'; + + let description = ''; + let reason = ''; + + if (activity.type === 'service_queue_started') { + description = `Start service queue (${activity.serviceCount} services)`; + reason = activity.reason || ''; + } else if (activity.type === 'service_paused') { + description = 'Pause service run'; + reason = activity.reason || ''; + } else if (activity.type === 'service_resumed') { + description = 'Resume service run'; + reason = activity.reason || ''; + } else if (activity.type === 'service_cancelled') { + description = 'Cancel service run'; + reason = activity.reason || ''; + } else if (activity.type === 'service_pdf') { + description = `Generate PDF report${activity.filename ? `: ${activity.filename}` : ''}`; + } + + return ( +
+
+ + +
+
{config.label}
+
{description}
+ {reason && ( +
{reason}
+ )} +
+ + {isPending && ( +
+ {onApprove && ( + + )} + {onReject && ( + + )} +
+ )} + + {isRunning && ( + + + Running... + + )} + + {isSuccess && ( + + + Done + + )} + + {isError && ( + + + {activity.error || 'Failed'} + + )} +
+
+ ); +} + +/** + * File operation approval block (for write_file, move_file, copy_file) + */ +function FileOperationBlock({ + activity, + onApprove, + onReject +}: { + activity: AgentActivity; + onApprove?: () => void; + onReject?: () => void; +}) { + const config = getActivityConfig(activity.type, activity.status); + const isPending = activity.status === 'pending_approval'; + const isRunning = activity.status === 'running'; + const isError = activity.status === 'error'; + const isSuccess = activity.status === 'success'; + + const formatSnippet = (value?: string) => { + if (!value) return ''; + const clean = value.replace(/\s+/g, ' ').trim(); + return clean.length > 60 ? `${clean.slice(0, 57)}...` : clean; + }; + + let description = ''; + if (activity.type === 'write_file') { + description = `Write to ${activity.path}`; + } else if (activity.type === 'edit_file') { + const oldSnippet = formatSnippet(activity.oldString); + const newSnippet = formatSnippet(activity.newString); + const change = oldSnippet || newSnippet ? `Replace "${oldSnippet}" \u2192 "${newSnippet}"` : 'Edit file contents'; + description = activity.path ? `${activity.path} \u00b7 ${change}` : change; + } else if (activity.type === 'generate_file') { + description = `Generate file: ${activity.filename}`; + } else if (activity.type === 'move_file') { + description = `Move ${activity.src} \u2192 ${activity.dest}`; + } else if (activity.type === 'copy_file') { + description = `Copy ${activity.src} \u2192 ${activity.dest}`; + } + + return ( +
+
+ + +
+
{config.label}
+
{description}
+
+ + {isPending && ( +
+ {onApprove && ( + + )} + {onReject && ( + + )} +
+ )} + + {isRunning && ( + + + Running... + + )} + + {isSuccess && ( + + + Done + + )} + + {isError && ( + + + {activity.error || 'Failed'} + + )} +
+ + {/* Expandable diff preview for edit_file */} + {activity.type === 'edit_file' && isPending && ( + + )} +
+ ); +} + +export function AgentActivityItem({ activity, onApprove, onReject }: AgentActivityItemProps) { + const config = getActivityConfig(activity.type, activity.status); + const [expanded, setExpanded] = useState(false); + const hasExpandableOutput = !!(activity.output || activity.error) && (activity.output?.length ?? 0) + (activity.error?.length ?? 0) > 80; + + // Terminal commands get special rendering + if (activity.type === 'ran_command') { + return ( + onApprove(activity.id) : undefined} + onReject={onReject ? () => onReject(activity.id) : undefined} + /> + ); + } + + // Service control activities get block rendering + if (['service_queue_started', 'service_paused', 'service_resumed', 'service_cancelled', 'service_pdf'].includes(activity.type)) { + return ( + onApprove(activity.id) : undefined} + onReject={onReject ? () => onReject(activity.id) : undefined} + /> + ); + } + + // File operations that need approval get block rendering + if (['edit_file', 'write_file', 'generate_file', 'move_file', 'copy_file'].includes(activity.type)) { + return ( + onApprove(activity.id) : undefined} + onReject={onReject ? () => onReject(activity.id) : undefined} + /> + ); + } + + // Standard inline rendering for other activities + return ( +
+
setExpanded(!expanded) : undefined} + > + + {config.label} + + {/* Activity-specific content */} + {activity.type === 'analyzed_directory' && ( + {activity.path} + )} + + {activity.type === 'searched' && ( + <> + "{activity.query}" + {activity.resultCount !== undefined && ( + {activity.resultCount} results + )} + + )} + + {activity.type === 'analyzed_file' && ( + + )} + + {activity.type === 'read_file' && ( + + )} + + {activity.type === 'list_dir' && ( + <> + {activity.path} + {activity.entryCount !== undefined && ( + {activity.entryCount} items + )} + + )} + + {activity.type === 'list_programs' && ( + <> + Portable programs + {activity.programCount !== undefined && ( + {activity.programCount} found + )} + + )} + + {activity.type === 'find_exe' && ( + <> + "{activity.query}" + {activity.matchCount !== undefined && ( + + {activity.matchCount} {activity.matchCount === 1 ? 'match' : 'matches'} + + )} + + )} + + {activity.type === 'web_search' && ( + <> + {activity.query} + {activity.resultCount !== undefined && ( + {activity.resultCount} results + )} + + )} + + {activity.type === 'get_system_info' && ( + Fetching hardware & OS details + )} + + {activity.type === 'mcp_tool' && ( +
+ {activity.toolName} + {activity.arguments && ( + {activity.arguments} + )} +
+ )} + + {activity.type === 'generate_file' && activity.status === 'success' && ( +
+ {activity.filename} + {activity.size !== undefined && ( + ({formatFileSize(activity.size)}) + )} + +
+ )} + + {activity.type === 'attach_files' && ( +
+ {activity.fileCount} file(s) + + {activity.files.map(f => f.name).join(', ')} + +
+ )} + + {/* Service query/report/edit inline display */} + {activity.type === 'service_query' && ( + {activity.queryType}{activity.detail ? `: ${activity.detail}` : ''} + )} + + {activity.type === 'service_report' && ( + {activity.reportAction}{activity.reportId ? ` (${activity.reportId})` : ''} + )} + + {activity.type === 'service_edit' && ( + {activity.editAction}{activity.detail ? `: ${activity.detail}` : ''} + )} + + {/* Status indicator for non-HITL activities */} + setExpanded(!expanded)} + /> +
+ + {/* Expanded output panel */} + {expanded && (activity.output || activity.error) && ( +
+          {activity.error || activity.output}
+        
+ )} +
+ ); +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export default AgentActivityItem; diff --git a/src/components/agent/AgentRightSidebar.tsx b/src/components/agent/AgentRightSidebar.tsx new file mode 100644 index 0000000..7f67418 --- /dev/null +++ b/src/components/agent/AgentRightSidebar.tsx @@ -0,0 +1,230 @@ +import { InstrumentList } from '@/components/agent/InstrumentList'; +import { FileCode, Info, Plug, AlertCircle, Package } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useSettings } from '@/components/settings-context'; +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { AgentSettings } from '@/types/agent'; +import type { MCPManagerState } from '@/lib/mcp-manager'; + +interface AgentRightSidebarProps { + className?: string; + onRunInstrument: (name: string) => void; + mcpState?: MCPManagerState; + toolSummary?: Array<{ id: string; name: string; desc: string; enabled: boolean; requiresApproval?: boolean }>; +} + +/** + * Quick info panel showing agent capabilities + */ +function AgentInfoPanel({ + mcpState, + toolSummary, +}: { + mcpState?: MCPManagerState; + toolSummary?: Array<{ id: string; name: string; desc: string; enabled: boolean; requiresApproval?: boolean }>; +}) { + const { settings } = useSettings(); + const agentSettings = settings.agent as AgentSettings | undefined; + + const tools = toolSummary || [ + { id: 'execute_command', name: 'Commands', desc: 'Execute PowerShell', enabled: true }, + { id: 'files', name: 'Files', desc: 'Read, write, copy, move', enabled: true }, + { id: 'get_system_info', name: 'System Info', desc: 'Hardware & OS details', enabled: true }, + { id: 'search_web', name: 'Web Search', desc: 'Search the internet', enabled: agentSettings?.searchProvider !== 'none' }, + { id: 'list_programs', name: 'Programs', desc: 'List portable tools', enabled: true }, + ]; + + const mcpServerCount = mcpState?.servers?.length || 0; + const mcpToolCount = mcpState?.toolCount || 0; + + return ( +
+
+

Capabilities

+
+ {tools.map(t => ( +
+
+ {t.name} + {t.desc} +
+
+ {t.requiresApproval && ( + + HITL + + )} + + {t.enabled ? 'On' : 'Off'} + +
+
+ ))} + {/* MCP Tools row */} +
+
+ MCP Tools + External servers +
+ 0 ? 'text-blue-500 border-blue-500/30' : 'text-muted-foreground border-muted' + )}> + {mcpServerCount > 0 ? `${mcpToolCount} tools` : 'Off'} + +
+
+
+ + {/* MCP Server Details */} + {mcpServerCount > 0 && ( +
+

+ + MCP Servers +

+
+ {mcpState?.servers.map(s => ( +
+
+ {s.config.name} + + {Object.keys(s.tools).length} tools + +
+
+ {Object.keys(s.tools).slice(0, 5).map(toolName => ( + + {toolName} + + ))} + {Object.keys(s.tools).length > 5 && ( + + +{Object.keys(s.tools).length - 5} more + + )} +
+
+ ))} +
+
+ )} + + {/* MCP Errors */} + {mcpState?.errors && mcpState.errors.length > 0 && ( +
+

+ + Connection Errors +

+
+ {mcpState.errors.map(err => ( +
+ {err.serverName}: {err.error} +
+ ))} +
+
+ )} + +
+

Model

+

+ {agentSettings?.model || 'Not configured'} +

+
+
+ ); +} + +/** + * Portable programs panel showing programs in data/programs + */ +function ProgramList() { + const [programs, setPrograms] = useState>>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + invoke>>('list_agent_programs') + .then(setPrograms) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return
Loading...
; + } + if (programs.length === 0) { + return
No programs in data/programs
; + } + return ( +
+ {programs.map(p => ( +
+ {p.name} + {p.executables && ( +
+ {p.executables} +
+ )} +
+ ))} +
+ ); +} + +/** + * Agent Right Sidebar + * Shows instruments, programs, and agent info + */ +export function AgentRightSidebar({ className, onRunInstrument, mcpState, toolSummary }: AgentRightSidebarProps) { + return ( +
+ {/* Instruments Section */} +
+
+ +

Instruments

+
+

Custom scripts for the agent

+
+ +
+ +
+ + {/* Programs Section */} +
+
+ +

Portable Programs

+
+
+ +
+ + + +
+ + {/* Info Section */} +
+
+
+ +

Agent Info

+
+
+ + + +
+
+ ); +} diff --git a/src/components/agent/ChatMessage.tsx b/src/components/agent/ChatMessage.tsx new file mode 100644 index 0000000..883b16d --- /dev/null +++ b/src/components/agent/ChatMessage.tsx @@ -0,0 +1,221 @@ +/** + * Chat Message Component + * + * Displays messages with interleaved text, tool activity, and file attachment parts. + * Supports linear flow: text โ†’ tool โ†’ text โ†’ tool โ†’ text โ†’ attachment + */ + +import { memo, useMemo } from 'react'; +import { User, Bot } from 'lucide-react'; +import { AgentActivityItem } from './AgentActivityItem'; +import { MemoizedMarkdown } from './MemoizedMarkdown'; +import { FileAttachmentComponent } from './FileAttachment'; +import type { MessageRole } from '@/types/agent'; +import type { AgentActivity } from '@/types/agent-activity'; +import type { FileAttachment } from '@/types/file-attachment'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface MessagePart { + type: 'text' | 'tool' | 'attachment'; + content?: string; + activity?: AgentActivity; + attachments?: FileAttachment[]; +} + +interface ChatMessageProps { + id?: string; + role: MessageRole; + content: string; + isStreaming?: boolean; + parts?: MessagePart[]; + timestamp?: string; + attachments?: FileAttachment[]; // Legacy support for direct attachments + onActivityApprove?: (activityId: string) => void; + onActivityReject?: (activityId: string) => void; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function formatTimestamp(ts: string | undefined): string | null { + if (!ts) return null; + try { + const d = new Date(ts); + if (isNaN(d.getTime())) return null; + return d.toLocaleTimeString(); + } catch { + return null; + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export const ChatMessage = memo(function ChatMessage({ + id, + role, + content, + isStreaming, + parts, + timestamp, + attachments, + onActivityApprove, + onActivityReject, +}: ChatMessageProps) { + const isUser = role === 'user'; + const isSystem = role === 'system'; + const messageId = id || `msg-${Date.now()}`; + const formattedTime = useMemo(() => formatTimestamp(timestamp), [timestamp]); + + // System messages + if (isSystem) { + return ( +
+ + {content} + +
+ ); + } + + // User messages โ€” right-aligned bubble + if (isUser) { + return ( +
+
+ +
+
+
+ You + {formattedTime && {formattedTime}} +
+
+

{content}

+ {/* User attachments */} + {attachments && attachments.length > 0 && ( +
+ {attachments.map(att => ( + + ))} +
+ )} +
+
+
+ ); + } + + // Assistant messages โ€” left-aligned with interleaved parts + const hasParts = Array.isArray(parts) && parts.length > 0; + const hasContent = !!content; + + return ( +
+ {/* Avatar */} +
+ +
+ + {/* Content */} +
+
+ Agent + {formattedTime && {formattedTime}} +
+ + {/* Render interleaved parts if available */} + {hasParts ? ( +
+ {parts.map((part, index) => { + if (part.type === 'text' && part.content) { + return ( +
+ +
+ ); + } + if (part.type === 'tool' && part.activity) { + return ( + + ); + } + if (part.type === 'attachment' && part.attachments && part.attachments.length > 0) { + return ( +
+ {part.attachments.map(att => ( + + ))} +
+ ); + } + return null; + })} +
+ ) : hasContent ? ( + /* Fallback: render plain content for loaded conversations */ +
+ +
+ ) : null} + + {/* Legacy attachments (not in parts) */} + {attachments && attachments.length > 0 && ( +
+ {attachments.map(att => ( + + ))} +
+ )} + + {/* Streaming indicator */} + {isStreaming && !hasContent && !hasParts && ( +
+ Thinking + + + + + +
+ )} + + {/* Streaming cursor after content */} + {isStreaming && (hasContent || hasParts) && ( + + )} +
+
+ ); +}); + +export default ChatMessage; + + + diff --git a/src/components/agent/ConversationSelector.tsx b/src/components/agent/ConversationSelector.tsx new file mode 100644 index 0000000..1565b7f --- /dev/null +++ b/src/components/agent/ConversationSelector.tsx @@ -0,0 +1,241 @@ +/** + * Conversation Selector Component + * + * Dropdown for managing and switching between saved conversations. + */ + +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + MessageSquarePlus, + ChevronDown, + Trash2, + MessageCircle, + Clock, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import type { Conversation } from '@/types/agent'; + +interface ConversationSelectorProps { + currentConversationId: string | null; + onSelect: (conversation: Conversation) => void; + onNew: () => void; + className?: string; +} + +/** + * Format relative time (e.g., "2 hours ago", "Yesterday") + */ +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +/** + * Truncate title for display + */ +function truncateTitle(title: string, maxLength = 30): string { + if (title.length <= maxLength) return title; + return title.substring(0, maxLength - 3) + '...'; +} + +export function ConversationSelector({ + currentConversationId, + onSelect, + onNew, + className, +}: ConversationSelectorProps) { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [conversationToDelete, setConversationToDelete] = useState(null); + + // Current conversation title + const currentConversation = conversations.find(c => c.id === currentConversationId); + const displayTitle = currentConversation + ? truncateTitle(currentConversation.title) + : 'New Chat'; + + // Load conversations + const loadConversations = async () => { + setLoading(true); + try { + const data = await invoke('list_conversations', { limit: 50 }); + setConversations(data); + } catch (err) { + console.error('Failed to load conversations:', err); + } finally { + setLoading(false); + } + }; + + // Load on mount and when dropdown opens + useEffect(() => { + loadConversations(); + }, []); + + useEffect(() => { + if (open) { + loadConversations(); + } + }, [open]); + + // Handle delete + const handleDelete = async () => { + if (!conversationToDelete) return; + + try { + await invoke('delete_conversation', { conversationId: conversationToDelete.id }); + setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id)); + + // If we deleted the current conversation, trigger new chat + if (conversationToDelete.id === currentConversationId) { + onNew(); + } + } catch (err) { + console.error('Failed to delete conversation:', err); + } finally { + setConversationToDelete(null); + setDeleteDialogOpen(false); + } + }; + + const confirmDelete = (e: React.MouseEvent, conversation: Conversation) => { + e.stopPropagation(); + setConversationToDelete(conversation); + setDeleteDialogOpen(true); + }; + + return ( + <> + + + + + + {/* New Chat */} + { + onNew(); + setOpen(false); + }} + className="gap-2" + > + + New Chat + + + + + {/* Conversation List */} + {loading ? ( +
+ Loading... +
+ ) : conversations.length === 0 ? ( +
+ No conversations yet +
+ ) : ( +
+ {conversations.map((conversation) => ( + { + onSelect(conversation); + setOpen(false); + }} + className={cn( + 'flex items-center justify-between gap-2 group', + conversation.id === currentConversationId && 'bg-accent' + )} + > +
+
+ {truncateTitle(conversation.title, 35)} +
+
+ + {formatRelativeTime(conversation.updatedAt)} +
+
+ + {/* Delete button */} + +
+ ))} +
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + + + + Delete Conversation? + + This will permanently delete "{conversationToDelete?.title}". This action cannot be undone. + + + + Cancel + + Delete + + + + + + ); +} + +export default ConversationSelector; diff --git a/src/components/agent/FileAttachment.tsx b/src/components/agent/FileAttachment.tsx new file mode 100644 index 0000000..bd2a151 --- /dev/null +++ b/src/components/agent/FileAttachment.tsx @@ -0,0 +1,334 @@ +/** + * File Attachment Component + * + * Displays file attachments in chat messages with download, preview, + * and content extraction capabilities. + */ + +import { useState, useCallback } from 'react'; +import { + File, + FileText, + FileCode, + FileImage, + FileAudio, + FileVideo, + Download, + Eye, + X, + Check, + Loader2, + ExternalLink, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import type { FileAttachment, FileCategory } from '@/types/file-attachment'; +import { formatFileSize } from '@/types/file-attachment'; +import { invoke } from '@tauri-apps/api/core'; + +interface FileAttachmentProps { + attachment: FileAttachment; + showPreview?: boolean; + showDownload?: boolean; + compact?: boolean; + className?: string; +} + +export function FileAttachmentComponent({ + attachment, + showPreview = true, + showDownload = true, + compact = false, + className, +}: FileAttachmentProps) { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [previewContent, setPreviewContent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadSuccess, setDownloadSuccess] = useState(false); + + const category = attachment.category; + + const getCategoryIcon = (cat: FileCategory) => { + const iconClass = "h-4 w-4"; + switch (cat) { + case 'image': + return ; + case 'code': + return ; + case 'document': + return ; + case 'media': + // Use audio or video icon based on mime type + if (attachment.mimeType.startsWith('audio/')) { + return ; + } + return ; + default: + return ; + } + }; + + const getCategoryColor = (cat: FileCategory) => { + switch (cat) { + case 'image': + return 'bg-purple-500/20 text-purple-500 border-purple-500/30'; + case 'code': + return 'bg-blue-500/20 text-blue-500 border-blue-500/30'; + case 'document': + return 'bg-yellow-500/20 text-yellow-500 border-yellow-500/30'; + case 'media': + return attachment.mimeType.startsWith('audio/') + ? 'bg-pink-500/20 text-pink-500 border-pink-500/30' + : 'bg-red-500/20 text-red-500 border-red-500/30'; + default: + return 'bg-muted text-muted-foreground border-border'; + } + }; + + const handlePreview = useCallback(async () => { + if (!showPreview) return; + + // For images, we can show directly if we have the content + if (category === 'image' && attachment.content) { + setPreviewContent(attachment.content); + setIsPreviewOpen(true); + return; + } + + // For text/code files, read content + if ((category === 'text' || category === 'code' || category === 'document') && + attachment.size < 1024 * 1024) { // Max 1MB for preview + setIsLoading(true); + try { + const content = await invoke('read_file_content', { + file_id: attachment.id, + }); + setPreviewContent(content); + setIsPreviewOpen(true); + } catch (error) { + console.error('Failed to read file:', error); + } finally { + setIsLoading(false); + } + } else { + // For other files, just show metadata + setIsPreviewOpen(true); + } + }, [attachment, category, showPreview]); + + const handleDownload = useCallback(async () => { + if (!showDownload) return; + + setIsDownloading(true); + try { + // Get file info to determine path + const info = await invoke<{ path: string }>('get_file_info', { + file_id: attachment.id, + }); + + // Open file with default application + const { openPath } = await import('@tauri-apps/plugin-opener'); + await openPath(info.path); + + setDownloadSuccess(true); + setTimeout(() => setDownloadSuccess(false), 2000); + } catch (error) { + console.error('Failed to open file:', error); + } finally { + setIsDownloading(false); + } + }, [attachment.id, showDownload]); + + const handleReveal = useCallback(async () => { + try { + const info = await invoke<{ path: string }>('get_file_info', { + file_id: attachment.id, + }); + + const { revealItemInDir } = await import('@tauri-apps/plugin-opener'); + await revealItemInDir(info.path); + } catch (error) { + console.error('Failed to reveal file:', error); + } + }, [attachment.id]); + + if (compact) { + return ( +
+ {getCategoryIcon(category)} + {attachment.originalName} + {formatFileSize(attachment.size)} +
+ ); + } + + return ( + <> +
+ {/* Icon */} +
+ {getCategoryIcon(category)} +
+ + {/* Info */} +
+
{attachment.originalName}
+
+ {formatFileSize(attachment.size)} + โ€ข + + {category} + + {attachment.source === 'generated' && ( + <> + โ€ข + + AI Generated + + + )} +
+
+ + {/* Actions */} +
+ {showPreview && ( + + )} + + {showDownload && ( + + )} +
+
+ + {/* Preview Dialog */} + + + + + {getCategoryIcon(category)} + {attachment.originalName} + + + +
+ {/* File Info */} +
+
+ Size:{' '} + {formatFileSize(attachment.size)} +
+
+ Type:{' '} + {attachment.mimeType} +
+
+ Source:{' '} + {attachment.source} +
+ {attachment.checksum && ( +
+ SHA-256:{' '} + + {attachment.checksum.slice(0, 16)}... + +
+ )} +
+ + {/* Content Preview */} + {previewContent ? ( + + {category === 'image' ? ( + {attachment.originalName} + ) : ( +
+                    {previewContent}
+                  
+ )} +
+ ) : category === 'image' ? ( +
+

Image preview not available

+
+ ) : ( +
+

+ Preview not available for this file type +

+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ + ); +} + +export default FileAttachmentComponent; diff --git a/src/components/agent/FileReference.tsx b/src/components/agent/FileReference.tsx new file mode 100644 index 0000000..b69f175 --- /dev/null +++ b/src/components/agent/FileReference.tsx @@ -0,0 +1,72 @@ +/** + * File Reference Component + * + * Displays a file reference with icon, name, and optional line range. + * Styled to match Claude/Cursor UI. + */ + +import { FileCode, FileText, FileJson, File, Settings } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface FileReferenceProps { + path: string; + filename?: string; + lineRange?: string; + className?: string; +} + +/** + * Get icon based on file extension + */ +function getFileIcon(filename: string) { + const ext = filename.split('.').pop()?.toLowerCase(); + + switch (ext) { + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + return { Icon: FileCode, color: 'text-blue-500' }; + case 'json': + return { Icon: FileJson, color: 'text-yellow-500' }; + case 'md': + case 'txt': + return { Icon: FileText, color: 'text-muted-foreground' }; + case 'toml': + case 'yaml': + case 'yml': + case 'config': + return { Icon: Settings, color: 'text-orange-500' }; + default: + return { Icon: File, color: 'text-muted-foreground' }; + } +} + +export function FileReference({ + path, + filename, + lineRange, + className, +}: FileReferenceProps) { + const displayName = filename || path.split(/[/\\]/).pop() || path; + const { Icon, color } = getFileIcon(displayName); + + return ( + + + {displayName} + {lineRange && ( + #{lineRange} + )} + + ); +} + +export default FileReference; diff --git a/src/components/agent/FileUploadZone.tsx b/src/components/agent/FileUploadZone.tsx new file mode 100644 index 0000000..f32add5 --- /dev/null +++ b/src/components/agent/FileUploadZone.tsx @@ -0,0 +1,278 @@ +/** + * File Upload Zone Component + * + * Drag-and-drop file upload area with progress tracking. + * Supports multiple files, size validation, and MIME type detection. + */ + +import { useState, useCallback, useRef } from 'react'; +import { Upload, X, File, Loader2, Check } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import type { FileAttachment, FileUploadState } from '@/types/file-attachment'; +import { formatFileSize, getCategoryFromExtension, FILE_SIZE_LIMITS } from '@/types/file-attachment'; +import { invoke } from '@tauri-apps/api/core'; + +interface FileUploadZoneProps { + onFilesUploaded: (attachments: FileAttachment[]) => void; + maxFiles?: number; + maxTotalSize?: number; + disabled?: boolean; +} + +export function FileUploadZone({ + onFilesUploaded, + maxFiles = 10, + maxTotalSize = FILE_SIZE_LIMITS.SMALL * 10, // 100MB default + disabled = false, +}: FileUploadZoneProps) { + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState([]); + const inputRef = useRef(null); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragging(true); + } + }, [disabled]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (disabled) return; + + const files = Array.from(e.dataTransfer.files); + handleFiles(files); + }, [disabled]); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + handleFiles(files); + // Reset input + if (inputRef.current) { + inputRef.current.value = ''; + } + }, []); + + const handleFiles = async (files: File[]) => { + // Check max files + if (uploads.length + files.length > maxFiles) { + alert(`Maximum ${maxFiles} files allowed`); + return; + } + + // Check total size + const currentSize = uploads.reduce((sum, u) => sum + u.totalBytes, 0); + const newSize = files.reduce((sum, f) => sum + f.size, 0); + if (currentSize + newSize > maxTotalSize) { + alert(`Total size would exceed ${formatFileSize(maxTotalSize)}`); + return; + } + + // Create upload states + const newUploads: FileUploadState[] = files.map(file => ({ + file, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + status: 'pending', + progress: 0, + bytesUploaded: 0, + totalBytes: file.size, + })); + + setUploads(prev => [...prev, ...newUploads]); + + // Upload each file + for (const upload of newUploads) { + await uploadFile(upload); + } + }; + + const uploadFile = async (uploadState: FileUploadState) => { + const { file, id } = uploadState; + + // Update status to uploading + setUploads(prev => prev.map(u => + u.id === id ? { ...u, status: 'uploading' } : u + )); + + try { + // Read file as base64 + const base64 = await fileToBase64(file); + + // Update progress + setUploads(prev => prev.map(u => + u.id === id ? { ...u, progress: 50 } : u + )); + + // Call Tauri command + const attachment = await invoke('save_uploaded_file', { + file_name: file.name, + mime_type: file.type || 'application/octet-stream', + size: file.size, + content_base64: base64, + }); + + // Update to complete + setUploads(prev => prev.map(u => + u.id === id ? { ...u, status: 'complete', progress: 100, attachment } : u + )); + + // Notify parent + onFilesUploaded([attachment]); + + // Remove from list after delay + setTimeout(() => { + setUploads(prev => prev.filter(u => u.id !== id)); + }, 2000); + + } catch (error) { + setUploads(prev => prev.map(u => + u.id === id ? { ...u, status: 'error', error: String(error) } : u + )); + } + }; + + const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Remove data URL prefix + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + const removeUpload = (id: string) => { + setUploads(prev => prev.filter(u => u.id !== id)); + }; + + const getFileIcon = (filename: string) => { + const category = getCategoryFromExtension(filename); + switch (category) { + case 'image': + return
; + case 'code': + return
; + case 'document': + return
; + default: + return
; + } + }; + + return ( +
+ {/* Drop Zone */} +
!disabled && inputRef.current?.click()} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer", + "hover:border-primary/50 hover:bg-primary/5", + isDragging && "border-primary bg-primary/10", + disabled && "opacity-50 cursor-not-allowed hover:border-border hover:bg-transparent", + uploads.length > 0 && "border-solid border-border bg-muted/30" + )} + > + + +
+
+ +
+
+ Click to upload + or drag and drop +
+
+ Max {maxFiles} files, up to {formatFileSize(FILE_SIZE_LIMITS.SMALL)} each +
+
+
+ + {/* Upload List */} + {uploads.length > 0 && ( +
+ {uploads.map(upload => ( +
+ {getFileIcon(upload.file.name)} + +
+
+ {upload.file.name} + + {formatFileSize(upload.totalBytes)} + +
+ + {upload.status === 'uploading' && ( +
+ + {upload.progress}% +
+ )} + + {upload.status === 'error' && ( + {upload.error} + )} + + {upload.status === 'complete' && ( + + + Uploaded + + )} +
+ + +
+ ))} +
+ )} +
+ ); +} + +export default FileUploadZone; diff --git a/src/components/agent/InstrumentList.tsx b/src/components/agent/InstrumentList.tsx new file mode 100644 index 0000000..9bc9fab --- /dev/null +++ b/src/components/agent/InstrumentList.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import type { Instrument } from '@/types/agent'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Play, FileCode, RefreshCw } from 'lucide-react'; + +interface InstrumentListProps { + onRunInstrument: (name: string) => void; + hideHeader?: boolean; +} + +export function InstrumentList({ onRunInstrument, hideHeader = false }: InstrumentListProps) { + const [instruments, setInstruments] = useState([]); + const [loading, setLoading] = useState(true); + + const loadInstruments = async () => { + setLoading(true); + try { + const data = await invoke('list_instruments'); + setInstruments(data); + } catch (err) { + console.error('Failed to load instruments:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadInstruments(); + }, []); + + const getExtensionColor = (ext: string) => { + switch (ext) { + case 'ps1': return 'text-blue-500 bg-blue-500/10 border-blue-500/20'; + case 'py': return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20'; + case 'js': return 'text-green-500 bg-green-500/10 border-green-500/20'; + case 'bat': + case 'cmd': return 'text-gray-500 bg-gray-500/10 border-gray-500/20'; + default: return 'text-purple-500 bg-purple-500/10 border-purple-500/20'; + } + }; + + return ( +
+ {!hideHeader && ( +
+
+

Instruments

+

Custom agent scripts

+
+ +
+ )} + + {hideHeader && ( +
+ +
+ )} + + + {loading ? ( +
Loading...
+ ) : instruments.length === 0 ? ( +
+ No instruments found in data/instruments +
+ ) : ( +
+ {instruments.map((inst) => ( + + +
+
+
+ + {inst.name} + + + .{inst.extension} + +
+

+ {inst.description || "No description provided."} +

+
+ +
+
+ + {inst.path} +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/agent/MemoizedMarkdown.tsx b/src/components/agent/MemoizedMarkdown.tsx new file mode 100644 index 0000000..2b365d4 --- /dev/null +++ b/src/components/agent/MemoizedMarkdown.tsx @@ -0,0 +1,183 @@ +/** + * Memoized Markdown Component + * + * Performance-optimized markdown rendering for streaming chat messages. + * Splits markdown into blocks and memoizes each block to prevent + * re-rendering the entire message on each token update. + * + * Supports GFM (GitHub Flavored Markdown) including tables, strikethrough, etc. + */ + +import { memo, useMemo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { marked } from 'marked'; + +/** + * Parse markdown content into discrete blocks using marked lexer. + * We keep table tokens as a single block so they render correctly. + */ +function parseMarkdownIntoBlocks(markdown: string): string[] { + if (!markdown) return []; + + try { + const tokens = marked.lexer(markdown); + return tokens.map(token => token.raw); + } catch { + return markdown.split(/\n\n+/).filter(Boolean); + } +} + +/** + * Memoized individual markdown block + */ +const MemoizedMarkdownBlock = memo( + function MemoizedMarkdownBlock({ content }: { content: string }) { + return ( + + {children}
+ + ); + }, + thead({ children }) { + return {children}; + }, + tbody({ children }) { + return {children}; + }, + tr({ children }) { + return {children}; + }, + th({ children }) { + return {children}; + }, + td({ children }) { + return {children}; + }, + // Code blocks + code({ className, children, ...props }) { + const match = /language-(\w+)/.exec(className || ''); + const isInline = !className && !String(children).includes('\n'); + + if (isInline) { + return ( + + {children} + + ); + } + + return ( +
+ {match?.[1] && ( +
+ {match[1]} +
+ )} +
+                  {children}
+                
+
+ ); + }, + pre({ children }) { + return <>{children}; + }, + // Lists + ul({ children }) { + return
    {children}
; + }, + ol({ children }) { + return
    {children}
; + }, + li({ children }) { + return
  • {children}
  • ; + }, + // Paragraphs + p({ children }) { + return

    {children}

    ; + }, + // Emphasis + strong({ children }) { + return {children}; + }, + em({ children }) { + return {children}; + }, + // Headers + h1({ children }) { + return

    {children}

    ; + }, + h2({ children }) { + return

    {children}

    ; + }, + h3({ children }) { + return

    {children}

    ; + }, + // Links + a({ href, children }) { + return ( + + {children} + + ); + }, + // Blockquotes + blockquote({ children }) { + return ( +
    + {children} +
    + ); + }, + // Horizontal rules + hr() { + return
    ; + }, + }} + > + {content} +
    + ); + }, + (prevProps, nextProps) => prevProps.content === nextProps.content +); + +MemoizedMarkdownBlock.displayName = 'MemoizedMarkdownBlock'; + +interface MemoizedMarkdownProps { + content: string; + id: string; +} + +/** + * Memoized Markdown Component + * + * Splits markdown into blocks and renders each block with memoization. + * This prevents re-rendering all blocks when new content is streamed. + */ +export const MemoizedMarkdown = memo(function MemoizedMarkdown({ content, id }: MemoizedMarkdownProps) { + const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]); + + if (!content) return null; + + return ( +
    + {blocks.map((block, index) => ( + + ))} +
    + ); +}); + +MemoizedMarkdown.displayName = 'MemoizedMarkdown'; + +export default MemoizedMarkdown; + + diff --git a/src/components/agent/ServiceRunMonitor.tsx b/src/components/agent/ServiceRunMonitor.tsx new file mode 100644 index 0000000..c128576 --- /dev/null +++ b/src/components/agent/ServiceRunMonitor.tsx @@ -0,0 +1,346 @@ +/** + * ServiceRunMonitor + * + * Inline chat component that shows service run progress. + * Displays a service list with status icons, expandable logs, + * elapsed time, control buttons, and a rich completion summary. + */ + +import { useState, useEffect, useRef } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { ServiceRunState, ServiceDefinition, FindingSeverity } from '@/types/service'; +import { + Pause, + Play, + X, + Activity, + CheckCircle2, + XCircle, + AlertTriangle, + ChevronDown, + ChevronRight, + Clock, + Info, + ShieldAlert, + AlertOctagon, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface ServiceRunMonitorProps { + reportId: string; + onPause: () => void; + onResume: () => void; + onCancel: () => void; +} + +const SEVERITY_ICON: Record = { + info: { icon: Info, className: 'text-chart-1' }, + success: { icon: CheckCircle2, className: 'text-chart-2' }, + warning: { icon: AlertTriangle, className: 'text-chart-4' }, + error: { icon: XCircle, className: 'text-destructive' }, + critical: { icon: AlertOctagon, className: 'text-destructive' }, +}; + +export function ServiceRunMonitor({ + reportId, + onPause, + onResume, + onCancel, +}: ServiceRunMonitorProps) { + const [state, setState] = useState(null); + const [logs, setLogs] = useState([]); + const [definitions, setDefinitions] = useState>(new Map()); + const [elapsed, setElapsed] = useState(0); + const [logsExpanded, setLogsExpanded] = useState(false); + const logEndRef = useRef(null); + const startTimeRef = useRef(Date.now()); + + // Load service definitions + useEffect(() => { + invoke('get_service_definitions').then(defs => { + const map = new Map(); + for (const d of defs) map.set(d.id, d); + setDefinitions(map); + }).catch(() => {}); + }, []); + + // Elapsed time counter + useEffect(() => { + const interval = setInterval(() => { + setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, []); + + // Listen to service events + useEffect(() => { + const unlisteners: Promise[] = []; + + invoke('get_service_run_state').then(s => setState(s)).catch(() => {}); + + unlisteners.push( + listen('service-state-changed', (event) => { + setState(event.payload); + }) + ); + + unlisteners.push( + listen<{ serviceId: string; log: string }>('service-log', (event) => { + setLogs(prev => { + const next = [...prev, event.payload.log]; + return next.length > 100 ? next.slice(-100) : next; + }); + }) + ); + + return () => { + unlisteners.forEach(p => p.then(fn => fn())); + }; + }, [reportId]); + + // Auto-scroll logs + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + const report = state?.currentReport; + const isRunning = state?.isRunning ?? false; + const isPaused = state?.isPaused ?? false; + const enabledQueue = report?.queue.filter(q => q.enabled) ?? []; + const total = enabledQueue.length; + const currentIndex = report?.currentServiceIndex ?? 0; + const completedCount = report?.results.length ?? 0; + const currentServiceId = enabledQueue[currentIndex]?.serviceId; + const progress = total > 0 ? (completedCount / total) * 100 : 0; + + const isComplete = report?.status === 'completed' || report?.status === 'failed' || report?.status === 'cancelled'; + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + const formatDuration = (ms: number) => { + const s = ms / 1000; + return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; + }; + + const statusIcon = isComplete + ? report?.status === 'completed' + ? + : report?.status === 'cancelled' + ? + : + : isPaused + ? + : ; + + const statusText = isComplete + ? report?.status === 'completed' + ? `Completed โ€” ${completedCount} services run` + : report?.status === 'cancelled' + ? 'Cancelled' + : `Failed โ€” ${completedCount}/${total} services` + : isPaused + ? `Paused at service ${completedCount + 1}/${total}` + : `Running service ${completedCount + 1}/${total}`; + + // Compute finding severity breakdown from results + const severityCounts = { critical: 0, error: 0, warning: 0, info: 0, success: 0 }; + const passCount = report?.results.filter(r => r.success).length ?? 0; + const failCount = report?.results.filter(r => !r.success).length ?? 0; + if (report) { + for (const result of report.results) { + for (const finding of result.findings) { + severityCounts[finding.severity] = (severityCounts[finding.severity] || 0) + 1; + } + } + } + + return ( +
    +
    + {/* Header */} +
    +
    + {statusIcon} + {statusText} +
    +
    + + + {formatTime(elapsed)} + + {isRunning && !isComplete && ( + <> + {isPaused ? ( + + ) : ( + + )} + + + )} +
    +
    + + {/* Progress Bar */} +
    +
    +
    + + {/* Service List */} +
    + {enabledQueue.map((item, i) => { + const result = report?.results.find(r => r.serviceId === item.serviceId); + const isCurrent = i === currentIndex && !isComplete; + const name = definitions.get(item.serviceId)?.name ?? item.serviceId; + const findingCount = result?.findings.length ?? 0; + + return ( +
    + {/* Status icon */} + {result ? ( + result.success ? ( + + ) : ( + + ) + ) : isCurrent ? ( + isPaused ? ( + + ) : ( + + ) + ) : ( +
    + )} + + {/* Service name */} + + {name} + + + {/* Duration */} + {result && ( + + {formatDuration(result.durationMs)} + + )} + + {/* Finding count badge */} + {result && findingCount > 0 && ( + + {findingCount} + + )} +
    + ); + })} +
    + + {/* Expandable Live Log Tail */} + {logs.length > 0 && !isComplete && ( +
    + +
    + {logs.slice(logsExpanded ? -50 : -5).map((log, i) => ( +
    {log}
    + ))} +
    +
    +
    + )} + + {/* Completion Summary */} + {isComplete && report && ( +
    + {/* Pass/Fail counts */} +
    + + + {passCount} passed + + {failCount > 0 && ( + + + {failCount} failed + + )} + {report.totalDurationMs && ( + + {formatDuration(report.totalDurationMs)} total + + )} +
    + + {/* Finding severity breakdown */} + {(severityCounts.critical + severityCounts.error + severityCounts.warning + severityCounts.info + severityCounts.success) > 0 && ( +
    + Findings: + {(['critical', 'error', 'warning', 'info', 'success'] as FindingSeverity[]) + .filter(s => severityCounts[s] > 0) + .map(severity => { + const config = SEVERITY_ICON[severity]; + const SevIcon = config.icon; + return ( + + + {severityCounts[severity]} + + ); + })} +
    + )} +
    + )} +
    +
    + ); +} diff --git a/src/components/agent/TerminalOutputBlock.tsx b/src/components/agent/TerminalOutputBlock.tsx new file mode 100644 index 0000000..75dae22 --- /dev/null +++ b/src/components/agent/TerminalOutputBlock.tsx @@ -0,0 +1,220 @@ +/** + * Terminal Output Block Component + * + * Collapsible terminal output display matching Claude/Cursor style. + * Shows working directory, command, output, and exit code. + * Uses CSS variable theme classes for consistent styling. + */ + +import { useState, useEffect } from 'react'; +import { + ChevronDown, + ChevronRight, + Copy, + Check, + X, + ExternalLink, + Terminal, + Loader2, + Play, + AlertCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import type { ActivityStatus } from '@/types/agent-activity'; + +interface TerminalOutputBlockProps { + command: string; + output?: string; + error?: string; + workingDirectory?: string; + exitCode?: number; + status?: ActivityStatus; + defaultExpanded?: boolean; + onApprove?: () => void; + onReject?: () => void; +} + +export function TerminalOutputBlock({ + command, + output, + error, + workingDirectory, + exitCode, + status = 'success', + defaultExpanded = false, + onApprove, + onReject, +}: TerminalOutputBlockProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [copied, setCopied] = useState(false); + + // Auto-expand when we get output or on error + useEffect(() => { + if (output || error || status === 'error') { + setExpanded(true); + } + }, [output, error, status]); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + const textToCopy = output || error || command; + await navigator.clipboard.writeText(textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const isRunning = status === 'running'; + const isPending = status === 'pending_approval'; + const isError = status === 'error' || (exitCode !== undefined && exitCode !== 0); + const isSuccess = status === 'success' && !isError; + const displayOutput = output || error || ''; + + return ( +
    + {/* Working Directory Header */} + {workingDirectory && ( +
    + {'\u229E'} + Working directory: + {workingDirectory} +
    + )} + + {/* Command + Output */} +
    + {/* Expand/Collapse Toggle */} + + + {/* Copy Button */} + + + {/* Command Line */} +
    +
    + + {'\u2026'}\\{workingDirectory?.split('\\').slice(-1)[0] || 'shell'} > + + {command ? ( + {command} + ) : ( + + (no command - malformed tool call) + + )} +
    +
    + + {/* Output (Collapsible) */} + {expanded && displayOutput && ( +
    +
    +              {displayOutput}
    +            
    +
    + )} + + {/* Scrollbar track visible indicator */} + {expanded && displayOutput && displayOutput.length > 500 && ( +
    + )} +
    + + {/* Footer */} +
    +
    + + Terminal command +
    + +
    + {isRunning && ( + + + Running... + + )} + + {isPending && ( +
    + + + Approval Required + + {onApprove && ( + + )} + {onReject && ( + + )} +
    + )} + + {!isRunning && !isPending && ( + + {isError ? ( + + ) : ( + + )} + {exitCode !== undefined ? `Exit code ${exitCode}` : (isError ? 'Failed' : 'Success')} + + )} +
    +
    +
    + ); +} + +export default TerminalOutputBlock; diff --git a/src/components/component-test/GamepadTestTab.tsx b/src/components/component-test/GamepadTestTab.tsx new file mode 100644 index 0000000..87254d3 --- /dev/null +++ b/src/components/component-test/GamepadTestTab.tsx @@ -0,0 +1,614 @@ +/** + * Gamepad/Controller Test Tab + * + * Tests game controllers using the browser Gamepad API. + * Shows visual controller layout, button states, stick positions, + * trigger values, deadzone control, and vibration testing. + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Gamepad2, + Vibrate, + RefreshCw, + CircleDot, + AlertCircle, +} from 'lucide-react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Slider } from '@/components/ui/slider'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; + +// Standard button labels for Xbox-style controllers +const BUTTON_LABELS = [ + 'A', 'B', 'X', 'Y', + 'LB', 'RB', 'LT', 'RT', + 'Back', 'Start', + 'L3', 'R3', + 'Up', 'Down', 'Left', 'Right', + 'Home', +]; + +// Face button colors (Xbox style) +const FACE_BUTTON_COLORS: Record = { + 'A': '#22c55e', // green + 'B': '#ef4444', // red + 'X': '#3b82f6', // blue + 'Y': '#eab308', // yellow +}; + +interface GamepadState { + index: number; + id: string; + buttons: { pressed: boolean; value: number }[]; + axes: number[]; + connected: boolean; + timestamp: number; +} + +function applyDeadzone(value: number, deadzone: number): number { + if (Math.abs(value) < deadzone) return 0; + const sign = value > 0 ? 1 : -1; + return sign * ((Math.abs(value) - deadzone) / (1 - deadzone)); +} + +// ============================================================================ +// VISUAL CONTROLLER LAYOUT +// ============================================================================ + +function ControllerVisual({ state, deadzone }: { state: GamepadState; deadzone: number }) { + const buttons = state.buttons; + const axes = state.axes; + + // Left stick: axes 0 (X), 1 (Y) + const lsX = applyDeadzone(axes[0] ?? 0, deadzone); + const lsY = applyDeadzone(axes[1] ?? 0, deadzone); + // Right stick: axes 2 (X), 3 (Y) + const rsX = applyDeadzone(axes[2] ?? 0, deadzone); + const rsY = applyDeadzone(axes[3] ?? 0, deadzone); + + return ( +
    + {/* Triggers and Bumpers */} +
    + + +
    +
    + + +
    + + {/* Main controller body */} +
    + {/* Left side: Left Stick + D-Pad */} +
    + + +
    + + {/* Center: Back, Home, Start */} +
    +
    + + + +
    +
    + + {/* Right side: Face Buttons + Right Stick */} +
    + + +
    +
    +
    + ); +} + +function TriggerBar({ label, value }: { label: string; value: number }) { + const pct = Math.round(value * 100); + return ( +
    +
    + {label} + {pct}% +
    +
    +
    0.01 ? '#f97316' : 'transparent', + }} + /> +
    +
    + ); +} + +function BumperIndicator({ label, pressed }: { label: string; pressed: boolean }) { + return ( +
    + {label} +
    + ); +} + +function SmallButton({ label, pressed }: { label: string; pressed: boolean }) { + return ( +
    + {label} +
    + ); +} + +function FaceButtons({ a, b, x, y }: { a: boolean; b: boolean; x: boolean; y: boolean }) { + const buttonData = [ + { label: 'Y', pressed: y, row: 0, col: 1 }, + { label: 'X', pressed: x, row: 1, col: 0 }, + { label: 'B', pressed: b, row: 1, col: 2 }, + { label: 'A', pressed: a, row: 2, col: 1 }, + ]; + + return ( +
    +
    + {[0, 1, 2].map(row => + [0, 1, 2].map(col => { + const btn = buttonData.find(b => b.row === row && b.col === col); + if (!btn) { + return
    ; + } + const color = FACE_BUTTON_COLORS[btn.label] ?? '#888'; + return ( +
    + {btn.label} +
    + ); + }) + )} +
    +
    + ); +} + +function DPad({ up, down, left, right }: { up: boolean; down: boolean; left: boolean; right: boolean }) { + const active = 'bg-primary text-primary-foreground'; + const inactive = 'bg-muted text-muted-foreground'; + + return ( +
    +
    +
    +
    + โ–ฒ +
    +
    +
    + โ—„ +
    +
    +
    + โ–บ +
    +
    +
    + โ–ผ +
    +
    +
    +
    + ); +} + +function StickVisual({ + label, + x, + y, + rawX, + rawY, + pressed, + deadzone, +}: { + label: string; + x: number; + y: number; + rawX: number; + rawY: number; + pressed: boolean; + deadzone: number; +}) { + const size = 100; + const center = size / 2; + const radius = size / 2 - 8; + + // Dot position (clamped) + const dotX = center + x * radius; + const dotY = center + y * radius; + + // Raw position (faint) + const rawDotX = center + rawX * radius; + const rawDotY = center + rawY * radius; + + return ( +
    + {label} + + {/* Deadzone circle */} + + {/* Crosshair */} + + + {/* Raw position (faint) */} + {deadzone > 0 && ( + + )} + {/* Active position */} + + +
    + {x.toFixed(2)}, {y.toFixed(2)} +
    +
    + ); +} + +// ============================================================================ +// RAW DATA TABLE +// ============================================================================ + +function RawDataTable({ state }: { state: GamepadState }) { + return ( +
    + {/* Buttons */} + + + Buttons ({state.buttons.length}) + + + + + + + + + + + + + + {state.buttons.map((btn, i) => ( + + + + + + + ))} + +
    #LabelStateValue
    {i}{BUTTON_LABELS[i] ?? `Btn ${i}`} + + {btn.pressed ? 'ON' : 'OFF'} + + {btn.value.toFixed(2)}
    +
    +
    +
    + + {/* Axes */} + + + Axes ({state.axes.length}) + + + + + + + + + + + + + {state.axes.map((axis, i) => { + const names = ['Left X', 'Left Y', 'Right X', 'Right Y']; + return ( + 0.1 ? 'bg-primary/10' : ''}> + + + + + ); + })} + +
    #NameValue
    {i}{names[i] ?? `Axis ${i}`}{axis.toFixed(4)}
    +
    +
    +
    +
    + ); +} + +// ============================================================================ +// MAIN GAMEPAD TEST TAB +// ============================================================================ + +export function GamepadTestTab() { + const [gamepads, setGamepads] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [deadzone, setDeadzone] = useState(0.1); + const [isPolling, setIsPolling] = useState(false); + const [showRawData, setShowRawData] = useState(false); + const rafRef = useRef(null); + + const readGamepads = useCallback((): GamepadState[] => { + const raw = navigator.getGamepads(); + const states: GamepadState[] = []; + for (let i = 0; i < raw.length; i++) { + const gp = raw[i]; + if (!gp) continue; + states.push({ + index: gp.index, + id: gp.id, + buttons: Array.from(gp.buttons).map(b => ({ pressed: b.pressed, value: b.value })), + axes: Array.from(gp.axes), + connected: gp.connected, + timestamp: gp.timestamp, + }); + } + return states; + }, []); + + const pollLoop = useCallback(() => { + const states = readGamepads(); + setGamepads(states); + rafRef.current = requestAnimationFrame(pollLoop); + }, [readGamepads]); + + // Start/stop polling + useEffect(() => { + if (isPolling) { + rafRef.current = requestAnimationFrame(pollLoop); + } + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [isPolling, pollLoop]); + + // Auto-start polling when a gamepad connects + useEffect(() => { + const onConnect = () => { + setIsPolling(true); + }; + const onDisconnect = () => { + const remaining = readGamepads(); + if (remaining.length === 0) { + setIsPolling(false); + } + }; + window.addEventListener('gamepadconnected', onConnect); + window.addEventListener('gamepaddisconnected', onDisconnect); + + // Check if any gamepads are already connected + if (readGamepads().length > 0) { + setIsPolling(true); + } + + return () => { + window.removeEventListener('gamepadconnected', onConnect); + window.removeEventListener('gamepaddisconnected', onDisconnect); + }; + }, [readGamepads]); + + const selectedGamepad = gamepads.find(g => g.index === selectedIndex) ?? gamepads[0] ?? null; + + const handleVibrate = async () => { + if (!selectedGamepad) return; + const raw = navigator.getGamepads(); + const gp = raw[selectedGamepad.index]; + if (!gp) return; + try { + const actuator = (gp as any).vibrationActuator; + if (actuator) { + await actuator.playEffect('dual-rumble', { + duration: 300, + strongMagnitude: 1.0, + weakMagnitude: 0.5, + }); + } + } catch { + // Vibration not supported + } + }; + + return ( +
    + {/* Connection Status */} + + + + + Controller Status + + + Connect a game controller via USB or Bluetooth, then press any button + + + + {gamepads.length === 0 ? ( + + + + No controller detected. Connect a gamepad and press any button to activate it. + + + ) : ( +
    + {/* Controller selector */} + {gamepads.length > 1 && ( +
    + {gamepads.map(gp => ( + + ))} +
    + )} + + {selectedGamepad && ( +
    +
    + + Connected + + + {selectedGamepad.id} + +
    +
    + + +
    +
    + )} +
    + )} +
    +
    + + {/* Deadzone Control */} + {selectedGamepad && ( + + + + Stick Deadzone + {deadzone.toFixed(2)} + + + + setDeadzone(v)} + min={0} + max={0.5} + step={0.01} + /> +
    + 0 (none) + 0.5 (max) +
    +
    +
    + )} + + {/* Controller Visual or Raw Data */} + {selectedGamepad && ( + showRawData ? ( + + ) : ( + + + Controller Input + + + + + + ) + )} +
    + ); +} diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx new file mode 100644 index 0000000..4b9bbe1 --- /dev/null +++ b/src/components/error-boundary.tsx @@ -0,0 +1,62 @@ +/** + * Error Boundary Component + * + * Catches rendering errors in child components and displays a recovery UI + * instead of a blank white screen. Required for production builds where + * React's development error overlay is not available. + */ + +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { AlertTriangle, RotateCcw } from 'lucide-react'; + +interface ErrorBoundaryProps { + children: ReactNode; + /** Optional label shown in the error UI to identify which section crashed */ + label?: string; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary] Caught rendering error:', error); + console.error('[ErrorBoundary] Component stack:', errorInfo.componentStack); + } + + render() { + if (this.state.hasError) { + return ( +
    + +

    + {this.props.label ? `${this.props.label} crashed` : 'Something went wrong'} +

    +

    + {this.state.error?.message || 'An unexpected error occurred during rendering.'} +

    + +
    + ); + } + + return this.props.children; + } +} diff --git a/src/components/floating-service-status.tsx b/src/components/floating-service-status.tsx new file mode 100644 index 0000000..28139fb --- /dev/null +++ b/src/components/floating-service-status.tsx @@ -0,0 +1,182 @@ +/** + * Floating Service Status + * + * Bottom-right floating pill that shows service run progress when the user + * navigates away from the service tab. Clicking it returns to the service tab. + */ + +import { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Loader2, + CheckCircle2, + XCircle, + ChevronRight, + Ban, +} from 'lucide-react'; + +import { Progress } from '@/components/ui/progress'; +import { useServiceRun } from '@/components/service-run-context'; +import { useAnimation } from '@/components/animation-context'; + +// ============================================================================= +// Types +// ============================================================================= + +interface FloatingServiceStatusProps { + activeTab: string; +} + +// ============================================================================= +// Component +// ============================================================================= + +export function FloatingServiceStatus({ activeTab }: FloatingServiceStatusProps) { + const { + isRunning, + phase, + completedCount, + totalCount, + currentServiceName, + progress, + failedCount, + } = useServiceRun(); + const { animationsEnabled } = useAnimation(); + + // Track completion dismissal + const [dismissed, setDismissed] = useState(false); + const [completionTimerId, setCompletionTimerId] = useState | null>(null); + + const isOnServiceTab = activeTab === 'service'; + const isComplete = phase === 'completed' || phase === 'failed' || phase === 'cancelled'; + + // Reset dismissed state when a new run starts + useEffect(() => { + if (isRunning) { + setDismissed(false); + if (completionTimerId) { + clearTimeout(completionTimerId); + setCompletionTimerId(null); + } + } + }, [isRunning]); + + // Auto-dismiss after completion (8 seconds) + useEffect(() => { + if (isComplete && !isOnServiceTab && !dismissed) { + const timer = setTimeout(() => { + setDismissed(true); + }, 8000); + setCompletionTimerId(timer); + return () => clearTimeout(timer); + } + }, [isComplete, isOnServiceTab, dismissed]); + + // Determine visibility + const shouldShow = !isOnServiceTab && !dismissed && (isRunning || (isComplete && !dismissed)); + + const navigateToService = () => { + window.dispatchEvent(new CustomEvent('navigate-tab', { detail: 'service' })); + setDismissed(true); + }; + + const passedCount = completedCount - failedCount; + + const pillContent = () => { + if (isRunning) { + return ( + <> +
    + +
    +

    + Running {completedCount}/{totalCount} services... +

    + {currentServiceName && ( +

    {currentServiceName}

    + )} +
    + +
    + + + ); + } + + if (phase === 'completed') { + return ( +
    + +
    +

    Service Complete

    +

    + {passedCount} passed{failedCount > 0 ? `, ${failedCount} failed` : ''} +

    +
    + View Results +
    + ); + } + + if (phase === 'failed') { + return ( +
    + +
    +

    Service Failed

    +

    + {failedCount} failed, {passedCount} passed +

    +
    + View Results +
    + ); + } + + if (phase === 'cancelled') { + return ( +
    + +
    +

    Service Cancelled

    +

    + {completedCount} of {totalCount} completed +

    +
    + View Results +
    + ); + } + + return null; + }; + + if (!animationsEnabled) { + if (!shouldShow) return null; + return ( +
    + {pillContent()} +
    + ); + } + + return ( + + {shouldShow && ( + + {pillContent()} + + )} + + ); +} diff --git a/src/components/printable-system-info.tsx b/src/components/printable-system-info.tsx new file mode 100644 index 0000000..69e9dda --- /dev/null +++ b/src/components/printable-system-info.tsx @@ -0,0 +1,268 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { SystemInfo, formatBytes } from '@/types'; +import type { BusinessSettings } from '@/types/settings'; + +interface PrintableSystemInfoProps { + systemInfo: SystemInfo; + businessSettings?: BusinessSettings; +} + +export function PrintableSystemInfo({ systemInfo, businessSettings }: PrintableSystemInfoProps) { + const [logoUrl, setLogoUrl] = useState(null); + + // Load business logo + useEffect(() => { + if (businessSettings?.logoPath) { + invoke('get_business_logo', { logoPath: businessSettings.logoPath }) + .then(url => setLogoUrl(url)) + .catch(() => setLogoUrl(null)); + } else { + setLogoUrl(null); + } + }, [businessSettings?.logoPath]); + + const hasBusiness = businessSettings?.enabled && businessSettings?.name; + const businessName = businessSettings?.name || 'RustService'; + const hostname = systemInfo.os.hostname || 'DEVICE'; + const systemIdentity = [ + systemInfo.systemProduct?.vendor, + systemInfo.systemProduct?.model, + ].filter(Boolean).join(' '); + + return ( +
    + {/* Header with Business Branding */} +
    +
    + {/* Business Logo */} + {hasBusiness && ( +
    + {logoUrl ? ( + {businessName} setLogoUrl(null)} + /> + ) : ( +
    + {businessName.charAt(0).toUpperCase()} +
    + )} +
    + )} +
    +

    {businessName}

    + {hasBusiness && businessSettings?.address && ( +

    {businessSettings.address}

    + )} + {hasBusiness && (businessSettings?.phone || businessSettings?.email) && ( +

    + {businessSettings.phone && {businessSettings.phone}} + {businessSettings.phone && businessSettings.email && โ€ข } + {businessSettings.email && {businessSettings.email}} +

    + )} + {hasBusiness && businessSettings?.website && ( +

    {businessSettings.website}

    + )} + {hasBusiness && (businessSettings?.abn || businessSettings?.tfn) && ( +

    + {businessSettings.abn && ABN: {businessSettings.abn}} + {businessSettings.abn && businessSettings.tfn && | } + {businessSettings.tfn && TFN: {businessSettings.tfn}} +

    + )} + {!hasBusiness && ( +

    System Specifications

    + )} +
    +
    + + {/* Device Details Box */} +
    +

    Device Details

    +

    + Device: {hostname} +

    + {systemIdentity && ( +

    + System: {systemIdentity} +

    + )} +

    + Date: {new Date().toLocaleDateString()} +

    +
    +
    + + {/* Divider */} +
    + +

    System Specifications

    + +
    + {/* Operating System */} +
    +

    Operating System

    +
    +
    Name: {systemInfo.os.name || 'N/A'}
    +
    Version: {systemInfo.os.osVersion || 'N/A'}
    +
    Build: {systemInfo.os.longOsVersion || 'N/A'}
    +
    Kernel: {systemInfo.os.kernelVersion || 'N/A'}
    +
    +
    + + {/* BIOS / Firmware */} + {systemInfo.bios && ( +
    +

    BIOS / Firmware

    +
    +
    Manufacturer: {systemInfo.bios.manufacturer || 'N/A'}
    +
    Version: {systemInfo.bios.version || 'N/A'}
    +
    Release Date: {systemInfo.bios.releaseDate || 'N/A'}
    +
    Serial: {systemInfo.bios.serialNumber || 'N/A'}
    +
    +
    + )} + + {/* Processor */} +
    +

    Processor (CPU)

    +
    +
    Model: {systemInfo.cpu.brand}
    +
    Vendor: {systemInfo.cpu.vendorId}
    +
    Cores: {systemInfo.cpu.physicalCores ?? 'N/A'} Physical / {systemInfo.cpu.logicalCpus} Logical
    +
    Base Speed: {systemInfo.cpu.frequencyMhz} MHz
    + {systemInfo.cpuSocket && ( +
    Socket: {systemInfo.cpuSocket}
    + )} + {systemInfo.cpuL2CacheKb != null && ( +
    L2 Cache: {systemInfo.cpuL2CacheKb.toLocaleString()} KB
    + )} + {systemInfo.cpuL3CacheKb != null && ( +
    L3 Cache: {(systemInfo.cpuL3CacheKb / 1024).toFixed(0)} MB
    + )} +
    +
    + + {/* Memory */} +
    +

    Memory (RAM)

    +
    +
    Total: {formatBytes(systemInfo.memory.totalMemory)}
    +
    Available: {formatBytes(systemInfo.memory.availableMemory)}
    +
    + {systemInfo.ramSlots.length > 0 && ( +
    +

    Installed Modules:

    + {systemInfo.ramSlots.map((slot, idx) => ( +
    +
    + Slot: + {slot.deviceLocator || slot.bankLabel || `Slot ${idx + 1}`} +
    + {slot.capacityBytes != null && ( +
    + Size: + {formatBytes(slot.capacityBytes)} +
    + )} + {slot.speedMhz != null && ( +
    + Speed: + {slot.speedMhz} MHz +
    + )} + {slot.manufacturer && ( +
    + Manufacturer: + {slot.manufacturer} +
    + )} + {slot.partNumber && ( +
    + Part Number: + {slot.partNumber} +
    + )} + {slot.memoryType && ( +
    + Type: + {slot.memoryType} +
    + )} +
    + ))} +
    + )} +
    + + {/* Motherboard */} + {systemInfo.motherboard && ( +
    +

    Motherboard

    +
    +
    Vendor: {systemInfo.motherboard.vendor || 'Unknown'}
    +
    Model: {systemInfo.motherboard.name || 'Unknown'}
    +
    Version: {systemInfo.motherboard.version || 'Unknown'}
    +
    Serial: {systemInfo.motherboard.serialNumber || 'Unknown'}
    +
    +
    + )} + + {/* Graphics */} + {systemInfo.gpu && ( +
    +

    Graphics (GPU)

    +
    +
    Model: {systemInfo.gpu.model}
    +
    Vendor: {systemInfo.gpu.vendor}
    +
    VRAM: {formatBytes(systemInfo.gpu.totalVram)}
    +
    +
    + )} + + {/* Storage */} + {systemInfo.disks.length > 0 && ( +
    +

    Storage Drives

    +
    + {systemInfo.disks.map((disk, idx) => ( +
    +
    + Drive {disk.name || 'Local Disk'}: + {disk.mountPoint} ({disk.fileSystem}) +
    +
    Type: {disk.diskType} {disk.isRemovable ? '(Removable)' : ''}
    +
    Capacity: {formatBytes(disk.totalSpace)}
    +
    Free Space: {formatBytes(disk.availableSpace)}
    +
    + ))} +
    +
    + )} + + {/* Network */} + {systemInfo.networks.length > 0 && ( +
    +

    Network Adapters

    +
    + {systemInfo.networks.map((net, idx) => ( +
    + {net.name} + {net.macAddress} +
    + ))} +
    +
    + )} +
    + +
    + Generated by RustService +
    +
    + ); +} diff --git a/src/components/service-renderers/AdwCleanerRenderer.tsx b/src/components/service-renderers/AdwCleanerRenderer.tsx index 815a106..d0358c5 100644 --- a/src/components/service-renderers/AdwCleanerRenderer.tsx +++ b/src/components/service-renderers/AdwCleanerRenderer.tsx @@ -7,13 +7,13 @@ import { Sparkles, Check, AlertTriangle, FolderOpen, FileX, Settings, Clock, Chrome, FileCode } from 'lucide-react'; import { Bar, BarChart, XAxis, YAxis, Cell, LabelList } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -66,7 +66,7 @@ function getCategoryColor(name: string): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { // Extract summary data from findings const summaryFinding = result.findings.find( (f) => (f.data as AdwCleanerSummaryData | undefined)?.type === 'adwcleaner_summary' @@ -77,27 +77,14 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!summaryData) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - Adware Cleanup - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Cleanup Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete cleanup'} -

    -
    -
    -
    + +
    +

    {errorFinding?.title || 'Cleanup Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete cleanup'} +

    +
    +
    ); } @@ -125,136 +112,114 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const totalCleaned = categories.reduce((acc, c) => acc + c.count, 0); + const statusBadge = { + label: isClean ? 'CLEAN' : hasIssues ? 'PARTIAL' : 'ITEMS CLEANED', + color: (isClean ? 'green' : hasIssues ? 'yellow' : 'blue') as 'green' | 'yellow' | 'blue', + }; + return ( -
    - - - -
    - {isClean ? ( - - ) : ( - - )} -
    - Adware Cleanup - - {isClean ? 'CLEAN' : hasIssues ? 'PARTIAL' : 'ITEMS CLEANED'} - -
    -
    - - {/* Summary Stats */} -
    -
    -

    Categories

    -

    {categories.length}

    -
    -
    -

    Items Cleaned

    -

    {totalCleaned}

    -
    -
    -

    Status

    -

    - {isClean ? 'Clean' : hasIssues ? 'Partial' : 'Done'} -

    -
    -
    + + {/* Summary Stats */} +
    +
    +

    Categories

    +

    {categories.length}

    +
    +
    +

    Items Cleaned

    +

    {totalCleaned}

    +
    +
    +

    Status

    +

    + {isClean ? 'Clean' : hasIssues ? 'Partial' : 'Done'} +

    +
    +
    - {/* Category Chart */} - {chartData.length > 0 && ( - - - - - `${value} items`} hideLabel />} - /> - - {chartData.map((entry, index) => ( - - ))} - value.toString()} - className="fill-foreground" - fontSize={12} - /> - - - - )} + {/* Category Chart */} + {chartData.length > 0 && ( + + + + + `${value} items`} hideLabel />} + /> + + {chartData.map((entry, index) => ( + + ))} + value.toString()} + className="fill-foreground" + fontSize={12} + /> + + + + )} - {/* Clean state message */} - {isClean && ( -
    - -
    -

    System is Clean

    -

    No adware or PUPs were detected

    -
    -
    - )} + {/* Clean state message */} + {isClean && ( +
    + +
    +

    System is Clean

    +

    No adware or PUPs were detected

    +
    +
    + )} - {/* Category Details */} - {categories.length > 0 && ( -
    -

    Cleaned Items by Category

    -
    - {categories.slice(0, 6).map((cat) => ( -
    - {cat.name === 'Registry' && } - {cat.name === 'Files' && } - {cat.name === 'Folders' && } - {cat.name === 'Services' && } - {cat.name === 'Tasks' && } - {cat.name === 'DLLs' && } - {cat.name.includes('Chromium') && } - {cat.name} - {cat.count} -
    - ))} + {/* Category Details */} + {categories.length > 0 && ( +
    +

    Cleaned Items by Category

    +
    + {categories.slice(0, 6).map((cat) => ( +
    + {cat.name === 'Registry' && } + {cat.name === 'Files' && } + {cat.name === 'Folders' && } + {cat.name === 'Services' && } + {cat.name === 'Tasks' && } + {cat.name === 'DLLs' && } + {cat.name.includes('Chromium') && } + {cat.name} + {cat.count}
    -
    - )} + ))} +
    +
    + )} - {/* Warning for failed items */} - {failed > 0 && ( -
    - - - {failed} item{failed !== 1 ? 's' : ''} could not be removed. Manual review may be required. - -
    - )} - - -
    + {/* Warning for failed items */} + {failed > 0 && ( +
    + + + {failed} item{failed !== 1 ? 's' : ''} could not be removed. Manual review may be required. + +
    + )} +
    ); } diff --git a/src/components/service-renderers/BatteryInfoRenderer.tsx b/src/components/service-renderers/BatteryInfoRenderer.tsx deleted file mode 100644 index bb5900d..0000000 --- a/src/components/service-renderers/BatteryInfoRenderer.tsx +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Battery Info Service Renderer - * - * Custom renderer for the battery-info service results. - * Shows battery health with radial gauge and detailed stats. - */ - -import { - Battery, - BatteryFull, - BatteryLow, - BatteryMedium, - BatteryWarning, - BatteryCharging, - Zap, - Heart, - RefreshCw, - Clock, - Info, -} from 'lucide-react'; -import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { ChartContainer, type ChartConfig } from '@/components/ui/chart'; -import type { ServiceRendererProps } from './index'; - -// ============================================================================= -// Types -// ============================================================================= - -interface BatteryData { - type: 'battery_status'; - batteryIndex: number; - chargePercent: number; - healthPercent: number; - healthStatus: string; - state: string; - technology: string; - cycleCount: number | null; - timeToFullSecs: number | null; - timeToEmptySecs: number | null; - vendor: string | null; - model: string | null; -} - -interface NoBatteryData { - type: 'no_battery'; -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -function getHealthColor(health: number): string { - if (health >= 80) return 'hsl(142, 71%, 45%)'; // green - if (health >= 60) return 'hsl(48, 96%, 53%)'; // yellow - if (health >= 40) return 'hsl(25, 95%, 53%)'; // orange - return 'hsl(0, 84%, 60%)'; // red -} - -function getHealthTextColor(health: number): string { - if (health >= 80) return 'text-green-500'; - if (health >= 60) return 'text-yellow-500'; - if (health >= 40) return 'text-orange-500'; - return 'text-red-500'; -} - -function getBatteryIcon(state: string, charge: number) { - if (state.toLowerCase().includes('charging')) { - return ; - } - if (charge >= 80) return ; - if (charge >= 50) return ; - if (charge >= 20) return ; - return ; -} - -function formatTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (hours > 0) { - return `${hours}h ${mins}m`; - } - return `${mins}m`; -} - -// ============================================================================= -// Findings Variant -// ============================================================================= - -function FindingsRenderer({ result }: ServiceRendererProps) { - // Check for no battery - const noBatteryFinding = result.findings.find( - (f) => (f.data as NoBatteryData | undefined)?.type === 'no_battery' - ); - - if (noBatteryFinding) { - return ( - - - -
    - -
    - Battery Health Check - - N/A - -
    -
    - -
    -
    - -
    -

    No Battery Detected

    -

    - This system does not have a battery installed. This is normal for desktop computers. -

    -
    -
    -
    - ); - } - - // Get battery data - const batteryFinding = result.findings.find( - (f) => (f.data as BatteryData | undefined)?.type === 'battery_status' - ); - const batteryData = batteryFinding?.data as BatteryData | undefined; - - if (!batteryData) { - return null; - } - - const { - chargePercent, - healthPercent, - healthStatus, - state, - technology, - cycleCount, - timeToFullSecs, - timeToEmptySecs, - } = batteryData; - - const chartData = [{ health: healthPercent, fill: getHealthColor(healthPercent) }]; - const chartConfig: ChartConfig = { - health: { - label: 'Health', - color: getHealthColor(healthPercent), - }, - }; - - const isHealthy = healthPercent >= 80; - - return ( -
    - - - -
    = 60 ? 'bg-green-500/20' : 'bg-yellow-500/20'}`}> - {getBatteryIcon(state, chargePercent)} -
    - Battery Health Check - - {healthStatus.toUpperCase()} - -
    -
    - -
    - {/* Health Radial Chart */} -
    -
    - - - - - - - {/* Centered text overlay */} -
    -
    - {healthPercent.toFixed(0)}% -
    -
    Health
    -
    -
    -
    - - {healthStatus} -
    -
    - - {/* Stats */} -
    - {/* Current Charge */} -
    - -
    -

    Current Charge

    -

    {chargePercent.toFixed(0)}%

    -
    -
    - - {/* State */} -
    - {getBatteryIcon(state, chargePercent)} -
    -

    State

    -

    {state.replace('_', ' ')}

    -
    -
    - - {/* Technology */} -
    - -
    -

    Technology

    -

    {technology.replace('_', '-')}

    -
    -
    - - {/* Cycle Count */} - {cycleCount !== null && ( -
    - -
    -

    Charge Cycles

    -

    {cycleCount}

    -
    -
    - )} - - {/* Time Estimate */} - {(timeToFullSecs || timeToEmptySecs) && ( -
    - -
    -

    - {timeToFullSecs ? 'Time to Full' : 'Time Remaining'} -

    -

    - {formatTime(timeToFullSecs || timeToEmptySecs || 0)} -

    -
    -
    - )} -
    -
    - - {/* Recommendation */} - {healthPercent < 80 && ( -
    - - - {healthPercent < 60 - ? 'Battery health is significantly degraded. Consider replacing the battery soon.' - : 'Battery is showing some wear. Monitor for further degradation.'} - -
    - )} -
    -
    -
    - ); -} - -// ============================================================================= -// Customer Print Variant -// ============================================================================= - -function CustomerRenderer({ result }: ServiceRendererProps) { - // Check for no battery - const noBatteryFinding = result.findings.find( - (f) => (f.data as NoBatteryData | undefined)?.type === 'no_battery' - ); - - if (noBatteryFinding) { - return ( -
    -
    -
    - -
    -
    -

    - Battery -

    -

    No Battery (Desktop)

    -

    This is normal for desktop computers

    -
    -
    โ€“
    -
    -
    - ); - } - - const batteryFinding = result.findings.find( - (f) => (f.data as BatteryData | undefined)?.type === 'battery_status' - ); - const batteryData = batteryFinding?.data as BatteryData | undefined; - - if (!batteryData) { - return null; - } - - const { chargePercent, healthPercent, healthStatus } = batteryData; - const isHealthy = healthPercent >= 80; - - return ( -
    -
    -
    - -
    -
    -

    - Battery Health -

    -

    - {healthStatus} ({healthPercent.toFixed(0)}%) -

    -

    - Currently at {chargePercent.toFixed(0)}% charge -

    - {!isHealthy && ( -

    - โš  {healthPercent < 60 ? 'Consider replacing battery' : 'Monitor battery health'} -

    - )} -
    -
    - {isHealthy ? 'โœ“' : 'โš '} -
    -
    -
    - ); -} - -// ============================================================================= -// Main Renderer -// ============================================================================= - -export function BatteryInfoRenderer(props: ServiceRendererProps) { - const { variant } = props; - - if (variant === 'customer') { - return ; - } - - return ; -} diff --git a/src/components/service-renderers/BatteryReportRenderer.tsx b/src/components/service-renderers/BatteryReportRenderer.tsx new file mode 100644 index 0000000..65ab809 --- /dev/null +++ b/src/components/service-renderers/BatteryReportRenderer.tsx @@ -0,0 +1,284 @@ +/** + * Battery Report Renderer + * + * Custom renderer for powercfg /batteryreport results. + * Shows battery health, capacity degradation, and history. + */ + +import { BatteryCharging, Info, Zap, Clock } from 'lucide-react'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface CapacityHistoryEntry { + date: string; + fullChargeCapacity: number; + designCapacity: number; +} + +interface BatteryData { + type: 'battery_report'; + noBattery?: boolean; + designCapacityMwh: number; + fullChargeCapacityMwh: number; + healthPercent: number; + cycleCount: number | null; + capacityHistory: CapacityHistoryEntry[]; + batteryName: string; + manufacturer: string; + chemistry: string; + chargePercent: number | null; + state: string | null; + technology: string | null; + timeToFullSecs: number | null; + timeToEmptySecs: number | null; +} + +function formatTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as BatteryData | undefined; + + if (!data || data.type !== 'battery_report') return null; + + if (data.noBattery) { + return ( + +
    +
    + +

    No battery detected. This appears to be a desktop system.

    +
    +
    +
    + ); + } + + const health = data.healthPercent; + const isGood = health >= 80; + const isDegraded = health >= 50 && health < 80; + const isCritical = health < 50 && health > 0; + + const getHealthColor = () => { + if (isCritical) return 'text-red-500'; + if (isDegraded) return 'text-yellow-500'; + return 'text-green-500'; + }; + + const getProgressColor = () => { + if (isCritical) return 'bg-red-500'; + if (isDegraded) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + // Build a simple capacity chart from history + const recentHistory = data.capacityHistory.slice(-12); + + const statusBadge = isGood + ? { label: 'Healthy', color: 'green' as const } + : isDegraded + ? { label: 'Degraded', color: 'yellow' as const } + : { label: 'Replace', color: 'red' as const }; + + return ( + +
    + {/* Health Gauge */} +
    +

    + {health.toFixed(1)}% +

    +

    Battery Health

    +
    +
    +
    +
    + + {/* Stats Grid */} +
    +
    +

    Design Capacity

    +

    {(data.designCapacityMwh / 1000).toFixed(1)} Wh

    +
    +
    +

    Current Full Charge

    +

    {(data.fullChargeCapacityMwh / 1000).toFixed(1)} Wh

    +
    + {data.cycleCount !== null && ( +
    +

    Cycle Count

    +

    {data.cycleCount}

    +
    + )} + {data.chemistry && ( +
    +

    Chemistry

    +

    {data.chemistry}

    +
    + )} + {data.chargePercent != null && ( +
    + +
    +

    Current Charge

    +

    {data.chargePercent.toFixed(0)}%

    +
    +
    + )} + {data.state && ( +
    + +
    +

    State

    +

    {data.state.replace('_', ' ')}

    +
    +
    + )} + {(data.timeToFullSecs || data.timeToEmptySecs) && ( +
    + +
    +

    + {data.timeToFullSecs ? 'Time to Full' : 'Time Remaining'} +

    +

    + {formatTime(data.timeToFullSecs || data.timeToEmptySecs || 0)} +

    +
    +
    + )} +
    + + {/* Battery Info */} + {(data.batteryName || data.manufacturer) && ( +
    +

    Battery Info

    +

    + {[data.manufacturer, data.batteryName].filter(Boolean).join(' โ€” ')} +

    +
    + )} + + {/* Capacity History Chart */} + {recentHistory.length > 1 && ( +
    +

    Capacity History

    +
    + {recentHistory.map((entry, i) => { + const pct = entry.designCapacity > 0 + ? (entry.fullChargeCapacity / entry.designCapacity) * 100 + : 0; + return ( +
    +
    = 80 ? 'bg-green-500/70' : pct >= 50 ? 'bg-yellow-500/70' : 'bg-red-500/70'}`} + style={{ height: `${Math.max(4, pct)}%` }} + /> +
    + ); + })} +
    +
    + {recentHistory[0]?.date} + {recentHistory[recentHistory.length - 1]?.date} +
    +
    + )} + + {/* Warning */} + {isCritical && ( +
    +

    + โš  Battery has significantly degraded. Consider replacement. +

    +
    + )} +
    + + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as BatteryData | undefined; + + if (!data || data.type !== 'battery_report') return null; + + if (data.noBattery) { + return ( +
    +
    +
    + +
    +
    +

    Battery: Not Applicable

    +

    Desktop system โ€” no battery present.

    +
    +
    +
    + ); + } + + const health = data.healthPercent; + const isGood = health >= 80; + + return ( +
    +
    +
    + +
    +
    +

    + {isGood ? 'โœ“' : 'โš '} Battery Health: {health.toFixed(0)}% +

    +

    + Capacity: {(data.fullChargeCapacityMwh / 1000).toFixed(1)} Wh of {(data.designCapacityMwh / 1000).toFixed(1)} Wh design + {data.cycleCount !== null && ` | ${data.cycleCount} cycles`} + {data.chargePercent != null && ` | ${data.chargePercent.toFixed(0)}% charged`} +

    + {health < 50 && ( +

    โš  Battery replacement recommended

    + )} +
    +
    + {health.toFixed(0)}% +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function BatteryReportRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/BleachBitRenderer.tsx b/src/components/service-renderers/BleachBitRenderer.tsx index 4d767e8..0f15099 100644 --- a/src/components/service-renderers/BleachBitRenderer.tsx +++ b/src/components/service-renderers/BleachBitRenderer.tsx @@ -6,7 +6,7 @@ */ import { Trash2, HardDrive, FileX, AlertTriangle, CheckCircle2 } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -26,7 +26,7 @@ interface BleachBitData { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as BleachBitData | undefined; @@ -38,54 +38,44 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const hasCleanup = data.space_recovered_bytes > 0 || data.files_deleted > 0; return ( - - - -
    - -
    - System Cleanup Results -
    -
    - - {/* Stats Grid */} -
    -
    - -

    {data.space_recovered_formatted}

    -

    Space Recovered

    -
    -
    - -

    {data.files_deleted}

    -

    Files Deleted

    -
    -
    - -

    {data.errors}

    -

    Errors

    -
    + + {/* Stats Grid */} +
    +
    + +

    {data.space_recovered_formatted}

    +

    Space Recovered

    +
    + +

    {data.files_deleted}

    +

    Files Deleted

    +
    +
    + +

    {data.errors}

    +

    Errors

    +
    +
    - {/* Status Message */} -
    -
    - {hasErrors ? ( - - ) : ( - - )} - - {hasErrors - ? `Cleanup completed with ${data.errors} error(s). Some items could not be removed.` - : hasCleanup - ? 'Cleanup completed successfully.' - : 'No items needed cleaning.'} - -
    + {/* Status Message */} +
    +
    + {hasErrors ? ( + + ) : ( + + )} + + {hasErrors + ? `Cleanup completed with ${data.errors} error(s). Some items could not be removed.` + : hasCleanup + ? 'Cleanup completed successfully.' + : 'No items needed cleaning.'} +
    - - +
    + ); } diff --git a/src/components/service-renderers/ChkdskRenderer.tsx b/src/components/service-renderers/ChkdskRenderer.tsx index 9d23b3d..b4a15b6 100644 --- a/src/components/service-renderers/ChkdskRenderer.tsx +++ b/src/components/service-renderers/ChkdskRenderer.tsx @@ -15,8 +15,8 @@ import { BadgeInfo, } from 'lucide-react'; import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, type ChartConfig } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -96,7 +96,7 @@ function getStatusInfo(data: ChkdskResultData): { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings.find( (f) => (f.data as ChkdskResultData | undefined)?.type === 'chkdsk_result' ); @@ -105,27 +105,14 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!data) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - Disk Check (CHKDSK) - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Check Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete disk check'} -

    -
    -
    -
    + +
    +

    {errorFinding?.title || 'Check Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete disk check'} +

    +
    +
    ); } @@ -136,96 +123,86 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const chartData = [{ name: 'health', value: data.foundNoProblems ? 100 : data.errorsFound ? 40 : 70, fill: status.color }]; const chartConfig: ChartConfig = { value: { label: 'Health', color: status.color } }; + const statusBadge = { + label: status.label, + color: (data.accessDenied || data.invalidDrive ? 'red' : data.errorsFound || data.volumeInUse ? 'yellow' : 'green') as 'red' | 'yellow' | 'green', + }; + return ( -
    - - - -
    - + +
    + {/* Status Chart */} +
    +
    + + + + + + +
    + {status.icon}
    - Disk Check (CHKDSK) - - {status.label} - - - - -
    - {/* Status Chart */} -
    -
    - - - - - - -
    - {status.icon} -
    -
    -
    -

    - {data.foundNoProblems ? 'No Problems' : data.madeCorrections ? 'Repaired' : data.errorsFound ? 'Errors Found' : 'Complete'} -

    -

    - Drive {data.drive} ({data.filesystemType || 'Unknown'}) -

    -
    +
    +
    +

    + {data.foundNoProblems ? 'No Problems' : data.madeCorrections ? 'Repaired' : data.errorsFound ? 'Errors Found' : 'Complete'} +

    +

    + Drive {data.drive} ({data.filesystemType || 'Unknown'}) +

    +
    +
    + + {/* Stats */} +
    +
    + +
    +

    Total Disk Space

    +

    {formatBytes(data.totalDiskKb)}

    +
    - {/* Stats */} -
    -
    - -
    -

    Total Disk Space

    -

    {formatBytes(data.totalDiskKb)}

    -
    -
    +
    + +
    +

    Available Space

    +

    {formatBytes(data.availableKb)}

    +
    +
    -
    - -
    -

    Available Space

    -

    {formatBytes(data.availableKb)}

    -
    + {(data.badSectorsKb ?? 0) > 0 && ( +
    + +
    +

    Bad Sectors

    +

    {formatBytes(data.badSectorsKb)}

    +
    + )} - {(data.badSectorsKb ?? 0) > 0 && ( -
    - -
    -

    Bad Sectors

    -

    {formatBytes(data.badSectorsKb)}

    -
    -
    - )} - - {data.durationSeconds && ( -
    - -
    -

    Scan Duration

    -

    {data.durationSeconds.toFixed(1)}s

    -
    -
    - )} + {data.durationSeconds && ( +
    + +
    +

    Scan Duration

    +

    {data.durationSeconds.toFixed(1)}s

    +
    -
    + )} +
    +
    - {/* Mode Info */} -
    - - - Mode: {data.mode.replace('_', ' ')} - {usedPercent > 0 && โ€ข {usedPercent}% used} - -
    -
    - -
    + {/* Mode Info */} +
    + + + Mode: {data.mode.replace('_', ' ')} + {usedPercent > 0 && โ€ข {usedPercent}% used} + +
    + ); } diff --git a/src/components/service-renderers/DiskSpaceRenderer.tsx b/src/components/service-renderers/DiskSpaceRenderer.tsx index d8276c3..0e234a9 100644 --- a/src/components/service-renderers/DiskSpaceRenderer.tsx +++ b/src/components/service-renderers/DiskSpaceRenderer.tsx @@ -6,8 +6,8 @@ */ import { HardDrive, AlertTriangle, CheckCircle2 } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -59,7 +59,7 @@ function formatBytes(bytes: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { // Extract summary data from findings const summaryFinding = result.findings.find( (f) => (f.data as DiskSummaryData | undefined)?.type === 'disk_summary' @@ -70,73 +70,58 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const hasIssues = drives.some((d) => d.usagePercent >= 85); return ( -
    - - - -
    - -
    - Disk Space Analysis - + {/* Drive Cards */} +
    + {drives.map((drive) => ( +
    +
    = 85 + ? 'bg-red-500/10' + : drive.usagePercent >= 70 + ? 'bg-yellow-500/10' + : 'bg-green-500/10' }`} > - {!hasIssues ? 'HEALTHY' : 'ATTENTION NEEDED'} - - - - - {/* Drive Cards */} -
    - {drives.map((drive) => ( -
    -
    = 85 - ? 'bg-red-500/10' - : drive.usagePercent >= 70 - ? 'bg-yellow-500/10' - : 'bg-green-500/10' + {drive.usagePercent >= 85 ? ( + + ) : ( + = 70 ? 'text-yellow-500' : 'text-green-500' }`} - > - {drive.usagePercent >= 85 ? ( - - ) : ( - = 70 ? 'text-yellow-500' : 'text-green-500' - }`} - /> - )} -
    -
    -
    - {drive.mountPoint} - - {drive.usagePercent}% - -
    - -
    - {formatBytes(drive.availableBytes)} free - {formatBytes(drive.totalBytes)} total -
    -
    + /> + )} +
    +
    +
    + {drive.mountPoint} + + {drive.usagePercent}% +
    - ))} + +
    + {formatBytes(drive.availableBytes)} free + {formatBytes(drive.totalBytes)} total +
    +
    -
    - -
    + ))} +
    + ); } diff --git a/src/components/service-renderers/DismRenderer.tsx b/src/components/service-renderers/DismRenderer.tsx index ef831fd..4b77ff2 100644 --- a/src/components/service-renderers/DismRenderer.tsx +++ b/src/components/service-renderers/DismRenderer.tsx @@ -6,8 +6,8 @@ */ import { Package, CheckCircle2, AlertTriangle, XCircle, Shield } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { StatusBadge } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -51,26 +51,11 @@ function getHealthIcon(state: string | null) { } } -function getHealthBadge(state: string | null) { - switch (state) { - case 'healthy': - return Healthy; - case 'repaired': - return Repaired; - case 'repairable': - return Repairable; - case 'corrupted': - return Corrupted; - default: - return Unknown; - } -} - // ============================================================================= // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as DismData | undefined; @@ -81,50 +66,51 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const lastStep = data.steps[data.steps.length - 1]; const overallHealthy = !data.corruption_detected || data.corruption_repaired; + const getStatusBadge = (): StatusBadge => { + switch (lastStep?.health_state) { + case 'healthy': return { label: 'Healthy', color: 'green' }; + case 'repaired': return { label: 'Repaired', color: 'green' }; + case 'repairable': return { label: 'Repairable', color: 'yellow' }; + case 'corrupted': return { label: 'Corrupted', color: 'red' }; + default: return { label: overallHealthy ? 'PASS' : 'FAIL', color: overallHealthy ? 'green' : 'red' }; + } + }; + return ( - - - -
    - -
    - Component Store Health - {lastStep?.health_state && ( - {getHealthBadge(lastStep.health_state)} + + {/* Summary */} +
    +
    + {overallHealthy ? ( + + ) : ( + )} - - - - {/* Summary */} -
    -
    - {overallHealthy ? ( - - ) : ( - - )} -
    -

    {finding?.title}

    -

    {finding?.description}

    -
    +
    +

    {finding?.title}

    +

    {finding?.description}

    +
    - {/* Step Results */} -
    -

    DISM Operations

    - {data.steps.map((step, index) => ( -
    - {getHealthIcon(step.health_state)} - {step.action} - - Exit: {step.exit_code} - -
    - ))} -
    -
    - + {/* Step Results */} +
    +

    DISM Operations

    + {data.steps.map((step, index) => ( +
    + {getHealthIcon(step.health_state)} + {step.action} + + Exit: {step.exit_code} + +
    + ))} +
    + ); } diff --git a/src/components/service-renderers/DriverAuditRenderer.tsx b/src/components/service-renderers/DriverAuditRenderer.tsx new file mode 100644 index 0000000..19971b1 --- /dev/null +++ b/src/components/service-renderers/DriverAuditRenderer.tsx @@ -0,0 +1,190 @@ +/** + * Driver Audit Renderer + * + * Custom renderer for driverquery results. + * Shows driver inventory with filterable table and problem highlights. + */ + +import { useState } from 'react'; +import { Cpu, Search } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface DriverEntry { + moduleName: string; + displayName: string; + driverType: string; + startMode: string; + state: string; + status: string; + linkDate: string; + path: string; +} + +interface DriverData { + type: 'driver_audit'; + totalDrivers: number; + runningDrivers: number; + stoppedDrivers: number; + problemDrivers: number; + drivers: DriverEntry[]; + showAll: boolean; + error?: boolean; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as DriverData | undefined; + const [search, setSearch] = useState(''); + + if (!data || data.type !== 'driver_audit') return null; + if (data.error) return null; + + const hasProblems = data.problemDrivers > 0; + + const filteredDrivers = data.drivers.filter(d => + d.displayName.toLowerCase().includes(search.toLowerCase()) || + d.moduleName.toLowerCase().includes(search.toLowerCase()) || + d.path.toLowerCase().includes(search.toLowerCase()) + ); + + const getStateBadge = (state: string, status: string) => { + if (status !== 'OK') { + return {status}; + } + if (state === 'Running') { + return Running; + } + if (state === 'Stopped') { + return Stopped; + } + return {state}; + }; + + return ( + +
    + {/* Stats */} +
    +
    +

    {data.totalDrivers}

    +

    Total

    +
    +
    +

    {data.runningDrivers}

    +

    Running

    +
    +
    +

    {data.stoppedDrivers}

    +

    Stopped

    +
    +
    0 ? 'bg-yellow-500/10 border-yellow-500/20' : 'bg-muted/30'}`}> +

    0 ? 'text-yellow-500' : ''}`}>{data.problemDrivers}

    +

    Issues

    +
    +
    + + {/* Search */} + {data.drivers.length > 5 && ( +
    + + setSearch(e.target.value)} + className="pl-9" + /> +
    + )} + + {/* Driver List */} +
    + {filteredDrivers.map((driver, i) => ( +
    +
    +
    +

    {driver.displayName}

    +

    + {driver.moduleName} ยท {driver.driverType} ยท {driver.startMode} +

    +
    + {getStateBadge(driver.state, driver.status)} +
    +
    + ))} + {filteredDrivers.length === 0 && ( +

    + {search ? 'No matching drivers' : 'No drivers to display'} +

    + )} +
    +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as DriverData | undefined; + + if (!data || data.type !== 'driver_audit') return null; + + const isGood = data.problemDrivers === 0; + + return ( +
    +
    +
    + +
    +
    +

    + {isGood ? 'โœ“ All Drivers Healthy' : `โš  ${data.problemDrivers} Driver Issue(s)`} +

    +

    + {data.totalDrivers} drivers installed: {data.runningDrivers} running, {data.stoppedDrivers} stopped +

    +
    +
    + {isGood ? 'โœ“' : 'โš '} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function DriverAuditRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/EnergyReportRenderer.tsx b/src/components/service-renderers/EnergyReportRenderer.tsx new file mode 100644 index 0000000..b1cd6fe --- /dev/null +++ b/src/components/service-renderers/EnergyReportRenderer.tsx @@ -0,0 +1,183 @@ +/** + * Energy Report Renderer + * + * Custom renderer for powercfg /energy results. + * Shows errors, warnings, and informational power items. + */ + +import { Zap, AlertTriangle, Info, XCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface EnergyItem { + severity: 'error' | 'warning' | 'info'; + category: string; + title: string; + description: string; +} + +interface EnergyData { + type: 'energy_report'; + errorCount: number; + warningCount: number; + infoCount: number; + items: EnergyItem[]; + duration: number; + error?: boolean; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as EnergyData | undefined; + + if (!data || data.type !== 'energy_report') return null; + if (data.error) return null; + + const hasErrors = data.errorCount > 0; + const hasWarnings = data.warningCount > 0; + + const errors = data.items.filter(i => i.severity === 'error'); + const warnings = data.items.filter(i => i.severity === 'warning'); + const infos = data.items.filter(i => i.severity === 'info'); + + const statusBadge = hasErrors + ? { label: `${data.errorCount} Error${data.errorCount !== 1 ? 's' : ''}`, color: 'red' as const } + : hasWarnings + ? { label: `${data.warningCount} Warning${data.warningCount !== 1 ? 's' : ''}`, color: 'yellow' as const } + : { label: 'No Issues', color: 'green' as const }; + + return ( + +
    + {/* Summary */} +
    +
    +

    Errors

    +

    {data.errorCount}

    +
    +
    +

    Warnings

    +

    {data.warningCount}

    +
    +
    +

    Info

    +

    {data.infoCount}

    +
    +
    + + {/* Error Items */} + {errors.length > 0 && ( +
    +

    + Errors +

    + {errors.map((item, i) => ( +
    +

    {item.title}

    + {item.description && ( +

    {item.description}

    + )} + {item.category && ( + {item.category} + )} +
    + ))} +
    + )} + + {/* Warning Items */} + {warnings.length > 0 && ( +
    +

    + Warnings +

    + {warnings.map((item, i) => ( +
    +

    {item.title}

    + {item.description && ( +

    {item.description}

    + )} + {item.category && ( + {item.category} + )} +
    + ))} +
    + )} + + {/* Info Items */} + {infos.length > 0 && ( +
    +

    + Informational +

    + {infos.map((item, i) => ( +
    +

    {item.title}

    + {item.description && ( +

    {item.description}

    + )} +
    + ))} +
    + )} + +

    + Trace duration: {data.duration}s +

    +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as EnergyData | undefined; + + if (!data || data.type !== 'energy_report') return null; + + const isGood = data.errorCount === 0 && data.warningCount === 0; + + return ( +
    +
    +
    + +
    +
    +

    + {isGood ? 'โœ“ Power Efficiency: Good' : `โš  ${data.errorCount} Power Issue(s) Found`} +

    +

    + {data.errorCount} error(s), {data.warningCount} warning(s) detected during power analysis. +

    +
    +
    + {isGood ? 'โœ“' : 'โš '} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function EnergyReportRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/FurmarkRenderer.tsx b/src/components/service-renderers/FurmarkRenderer.tsx index 9065020..e7c9b4f 100644 --- a/src/components/service-renderers/FurmarkRenderer.tsx +++ b/src/components/service-renderers/FurmarkRenderer.tsx @@ -14,8 +14,8 @@ import { Activity, } from 'lucide-react'; import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, type ChartConfig } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -76,7 +76,7 @@ function getTempStatus(temp: number): { color: string; textColor: string; label: // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings.find( (f) => (f.data as FurmarkResultData | undefined)?.type === 'furmark_result' ); @@ -85,27 +85,14 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!data) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - GPU Stress Test (FurMark) - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Test Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete GPU stress test'} -

    -
    -
    -
    + +
    +

    {errorFinding?.title || 'Test Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete GPU stress test'} +

    +
    +
    ); } @@ -119,104 +106,91 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const chartData = [{ name: 'temp', value: tempValue, fill: tempStatus.color }]; const chartConfig: ChartConfig = { value: { label: 'Temperature', color: tempStatus.color } }; + const statusBadge = { + label: tempStatus.label, + color: (temp >= 95 ? 'red' : temp >= 85 ? 'yellow' : 'green') as 'red' | 'yellow' | 'green', + }; + return ( -
    - - - -
    - + +
    + {/* Temperature Gauge */} +
    +
    + + + + + + +
    + + {temp}ยฐC
    - GPU Stress Test (FurMark) - - {tempStatus.label} - - - - -
    - {/* Temperature Gauge */} -
    -
    - - - - - - -
    - - {temp}ยฐC -
    -
    -
    -

    Max Temperature

    - {gpu?.name && ( -

    {gpu.name}

    - )} -
    +
    +
    +

    Max Temperature

    + {gpu?.name && ( +

    {gpu.name}

    + )} +
    +
    + + {/* Stats */} +
    +
    + +
    +

    Average FPS

    +

    {fpsAvg}

    +
    - {/* Stats */} -
    -
    - -
    -

    Average FPS

    -

    {fpsAvg}

    -
    + {data.fps && ( +
    + +
    +

    FPS Range

    +

    + {data.fps.min} - {data.fps.max} +

    +
    + )} - {data.fps && ( -
    - -
    -

    FPS Range

    -

    - {data.fps.min} - {data.fps.max} -

    -
    -
    - )} - - {gpu?.max_usage_percent !== null && ( -
    - -
    -

    GPU Usage

    -

    {gpu.max_usage_percent}%

    -
    -
    - )} + {gpu?.max_usage_percent !== null && ( +
    + +
    +

    GPU Usage

    +

    {gpu.max_usage_percent}%

    +
    +
    + )} - {data.frames !== null && ( -
    - -
    -

    Total Frames

    -

    {data.frames.toLocaleString()}

    -
    -
    - )} + {data.frames !== null && ( +
    + +
    +

    Total Frames

    +

    {data.frames.toLocaleString()}

    +
    -
    + )} +
    +
    - {/* Resolution & Duration Info */} -
    - {data.resolution && ( - Resolution: {data.resolution.width}x{data.resolution.height} - )} - {data.durationMs && ( - Duration: {(data.durationMs / 1000).toFixed(1)}s - )} - {data.api && API: {data.api}} -
    -
    - -
    + {/* Resolution & Duration Info */} +
    + {data.resolution && ( + Resolution: {data.resolution.width}x{data.resolution.height} + )} + {data.durationMs && ( + Duration: {(data.durationMs / 1000).toFixed(1)}s + )} + {data.api && API: {data.api}} +
    + ); } diff --git a/src/components/service-renderers/InstalledSoftwareRenderer.tsx b/src/components/service-renderers/InstalledSoftwareRenderer.tsx new file mode 100644 index 0000000..80d277f --- /dev/null +++ b/src/components/service-renderers/InstalledSoftwareRenderer.tsx @@ -0,0 +1,225 @@ +/** + * Installed Software Renderer + * + * Custom renderer for software audit results. + * Shows program list with sizes, versions, and recent installations. + */ + +import { useState } from 'react'; +import { PackageSearch, Search, ArrowUpDown } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface ProgramEntry { + name: string; + version: string; + publisher: string; + installDate: string; + sizeMb: number; +} + +interface TopEntry { + name: string; + sizeMb: number; +} + +interface RecentEntry { + name: string; + version: string; + installDate: string; +} + +interface SoftwareData { + type: 'installed_software'; + totalPrograms: number; + totalSizeMb: number; + programs: ProgramEntry[]; + topBySize: TopEntry[]; + recentlyInstalled: RecentEntry[]; + includeUpdates: boolean; + error?: boolean; +} + +type SortKey = 'name' | 'size' | 'date' | 'publisher'; + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as SoftwareData | undefined; + const [search, setSearch] = useState(''); + const [sortBy, setSortBy] = useState('size'); + const [showAll, setShowAll] = useState(false); + + if (!data || data.type !== 'installed_software') return null; + if (data.error) return null; + + const filteredPrograms = data.programs + .filter(p => + p.name.toLowerCase().includes(search.toLowerCase()) || + p.publisher.toLowerCase().includes(search.toLowerCase()) + ) + .sort((a, b) => { + switch (sortBy) { + case 'name': return a.name.localeCompare(b.name); + case 'size': return b.sizeMb - a.sizeMb; + case 'date': return b.installDate.localeCompare(a.installDate); + case 'publisher': return a.publisher.localeCompare(b.publisher); + default: return 0; + } + }); + + const displayPrograms = showAll ? filteredPrograms : filteredPrograms.slice(0, 50); + + return ( + +
    + {/* Stats */} +
    +
    +

    {data.totalPrograms}

    +

    Programs

    +
    +
    +

    {data.totalSizeMb >= 1024 ? `${(data.totalSizeMb / 1024).toFixed(1)}` : data.totalSizeMb.toFixed(0)}

    +

    {data.totalSizeMb >= 1024 ? 'GB Total' : 'MB Total'}

    +
    +
    +

    {data.recentlyInstalled.length}

    +

    Recent

    +
    +
    + + {/* Top by Size */} + {data.topBySize.length > 0 && ( +
    +

    Largest Programs

    +
    + {data.topBySize.slice(0, 5).map((prog, i) => ( +
    +
    +
    + {i + 1}. + {prog.name} +
    +
    + + {prog.sizeMb >= 1024 ? `${(prog.sizeMb / 1024).toFixed(1)} GB` : `${prog.sizeMb.toFixed(0)} MB`} + +
    + ))} +
    +
    + )} + + {/* Search + Sort */} +
    +
    + + setSearch(e.target.value)} + className="pl-9" + /> +
    + +
    + + {/* Program List */} +
    + {displayPrograms.map((prog, i) => ( +
    +
    +

    {prog.name}

    +

    + {[prog.version, prog.publisher, prog.installDate].filter(Boolean).join(' ยท ')} +

    +
    + {prog.sizeMb > 0 && ( + + {prog.sizeMb >= 1024 ? `${(prog.sizeMb / 1024).toFixed(1)} GB` : `${prog.sizeMb.toFixed(0)} MB`} + + )} +
    + ))} +
    + + {!showAll && filteredPrograms.length > 50 && ( + + )} +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as SoftwareData | undefined; + + if (!data || data.type !== 'installed_software') return null; + + return ( +
    +
    +
    + +
    +
    +

    + Software Inventory: {data.totalPrograms} Programs +

    +

    + Total estimated disk usage: {data.totalSizeMb >= 1024 ? `${(data.totalSizeMb / 1024).toFixed(1)} GB` : `${data.totalSizeMb.toFixed(0)} MB`} +

    +
    +
    {data.totalPrograms}
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function InstalledSoftwareRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/IperfRenderer.tsx b/src/components/service-renderers/IperfRenderer.tsx index c83f18d..cbb9c65 100644 --- a/src/components/service-renderers/IperfRenderer.tsx +++ b/src/components/service-renderers/IperfRenderer.tsx @@ -7,13 +7,13 @@ import { Network, Activity, TrendingUp, AlertTriangle, Info, ChartLine } from 'lucide-react'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -68,7 +68,7 @@ function getScoreBgColor(score: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as IperfData | undefined; @@ -92,128 +92,120 @@ function FindingsRenderer({ result }: ServiceRendererProps) { }, }; + const statusBadge = { + label: `${score.toFixed(0)}/100`, + color: (score >= 70 ? 'green' : score >= 50 ? 'yellow' : 'red') as 'green' | 'yellow' | 'red', + }; + return ( -
    - - - -
    - -
    - Network Stability Test - - {score.toFixed(0)}/100 - -
    -
    - - {/* Throughput Chart */} - {chartData.length > 0 && ( -
    -
    - - - {direction === 'download' ? 'Download' : 'Upload'} Throughput Over Time - -
    - - - - - - - - - - `${value}s`} - /> - `${value}`} - label={{ value: 'Mbps', angle: -90, position: 'insideLeft', style: { textAnchor: 'middle' } }} - /> - `${value}s`} - indicator="dot" - /> - } - /> - - - + +
    + {/* Throughput Chart */} + {chartData.length > 0 && ( +
    +
    + + + {direction === 'download' ? 'Download' : 'Upload'} Throughput Over Time +
    - )} + + + + + + + + + + `${value}s`} + /> + `${value}`} + label={{ value: 'Mbps', angle: -90, position: 'insideLeft', style: { textAnchor: 'middle' } }} + /> + `${value}s`} + indicator="dot" + /> + } + /> + + + +
    + )} - {/* Stats Grid */} -
    -
    -

    Mean

    -

    {stats.mean.toFixed(1)} Mbps

    -
    -
    -

    Range

    -

    {stats.min.toFixed(0)}โ€“{stats.max.toFixed(0)}

    -
    -
    -

    Variability

    -

    0.1 ? 'text-yellow-500' : 'text-green-500'}`}> - {(stats.cov * 100).toFixed(1)}% -

    -
    -
    -

    Samples

    -

    {stats.samples}

    -
    + {/* Stats Grid */} +
    +
    +

    Mean

    +

    {stats.mean.toFixed(1)} Mbps

    +
    +
    +

    Range

    +

    {stats.min.toFixed(0)}โ€“{stats.max.toFixed(0)}

    +
    +
    +

    Variability

    +

    0.1 ? 'text-yellow-500' : 'text-green-500'}`}> + {(stats.cov * 100).toFixed(1)}% +

    +
    +
    +

    Samples

    +

    {stats.samples}

    +
    - {/* Retransmits Warning */} - {retransmits !== undefined && retransmits > 0 && ( -
    50 ? 'bg-red-500/10 text-red-500' : 'bg-yellow-500/10 text-yellow-500'}`}> - - {retransmits} TCP retransmit(s) during test -
    - )} + {/* Retransmits Warning */} + {retransmits !== undefined && retransmits > 0 && ( +
    50 ? 'bg-red-500/10 text-red-500' : 'bg-yellow-500/10 text-yellow-500'}`}> + + {retransmits} TCP retransmit(s) during test +
    + )} - {/* Verdict */} -
    - -
    - {verdict} - - to {server} - -
    - + {/* Verdict */} +
    + +
    + {verdict} + + to {server} +
    + +
    - {/* Recommendation */} - {finding?.recommendation && ( -
    - - - {finding.recommendation} - -
    - )} - - -
    + {/* Recommendation */} + {finding?.recommendation && ( +
    + + + {finding.recommendation} + +
    + )} +
    + ); } diff --git a/src/components/service-renderers/KvrtScanRenderer.tsx b/src/components/service-renderers/KvrtScanRenderer.tsx index 2092b37..ec3db7a 100644 --- a/src/components/service-renderers/KvrtScanRenderer.tsx +++ b/src/components/service-renderers/KvrtScanRenderer.tsx @@ -7,8 +7,8 @@ import { Shield, ShieldAlert, ShieldCheck, AlertTriangle, CheckCircle2, FileWarning } from 'lucide-react'; import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, type ChartConfig } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -53,7 +53,7 @@ function getSeverityTextColor(detected: number, removed: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { // Extract summary data from findings const summaryFinding = result.findings.find( (f) => (f.data as KvrtSummaryData | undefined)?.type === 'kvrt_summary' @@ -64,27 +64,18 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!summaryData) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - Virus Scan (KVRT) - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Scan Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete virus scan'} -

    -
    -
    -
    + +
    +

    {errorFinding?.title || 'Scan Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete virus scan'} +

    +
    +
    ); } @@ -111,138 +102,118 @@ function FindingsRenderer({ result }: ServiceRendererProps) { }; return ( -
    - - - -
    + +
    + {/* Status Radial Chart */} +
    +
    + + + + + + + {/* Centered icon */} +
    {isClean ? ( - + ) : ( - + )}
    - Virus Scan (KVRT) - - {isClean ? 'CLEAN' : allRemoved ? 'THREATS REMOVED' : 'THREATS DETECTED'} - - - - -
    - {/* Status Radial Chart */} -
    -
    - - - - - - - {/* Centered icon */} -
    - {isClean ? ( - - ) : ( - - )} -
    -
    -
    -

    - {isClean ? 'No Threats' : `${detected} Threat${detected !== 1 ? 's' : ''}`} -

    -

    - {processed.toLocaleString()} objects scanned -

    -
    +
    +
    +

    + {isClean ? 'No Threats' : `${detected} Threat${detected !== 1 ? 's' : ''}`} +

    +

    + {processed.toLocaleString()} objects scanned +

    +
    +
    + + {/* Stats */} +
    +
    + +
    +

    Objects Processed

    +

    {processed.toLocaleString()}

    +
    - {/* Stats */} -
    -
    - -
    -

    Objects Processed

    -

    {processed.toLocaleString()}

    -
    -
    +
    + 0 ? 'text-red-500' : 'text-green-500'}`} /> +
    +

    Threats Detected

    +

    0 ? 'text-red-500' : ''}`}>{detected}

    +
    +
    -
    - 0 ? 'text-red-500' : 'text-green-500'}`} /> -
    -

    Threats Detected

    -

    0 ? 'text-red-500' : ''}`}>{detected}

    -
    + {detected > 0 && ( +
    + = detected ? 'text-green-500' : 'text-yellow-500'}`} /> +
    +

    Threats Removed

    +

    {removed}

    - - {detected > 0 && ( -
    - = detected ? 'text-green-500' : 'text-yellow-500'}`} /> -
    -

    Threats Removed

    -

    {removed}

    -
    -
    - )} - - {(summaryData.passwordProtected ?? 0) > 0 && ( -
    - -
    -

    Password Protected

    -

    {summaryData.passwordProtected}

    -
    -
    - )}
    -
    + )} - {/* Detection List */} - {summaryData.detections.length > 0 && ( -
    -

    Detected Threats

    -
    - {summaryData.detections.map((detection, idx) => ( -
    -
    -
    -

    {detection.threat}

    -

    - {detection.objectPath} -

    -
    - {detection.action && ( - - {detection.action} - - )} -
    -
    - ))} + {(summaryData.passwordProtected ?? 0) > 0 && ( +
    + +
    +

    Password Protected

    +

    {summaryData.passwordProtected}

    )} - - -
    +
    +
    + + {/* Detection List */} + {summaryData.detections.length > 0 && ( +
    +

    Detected Threats

    +
    + {summaryData.detections.map((detection, idx) => ( +
    +
    +
    +

    {detection.threat}

    +

    + {detection.objectPath} +

    +
    + {detection.action && ( + + {detection.action} + + )} +
    +
    + ))} +
    +
    + )} + ); } diff --git a/src/components/service-renderers/NetworkConfigRenderer.tsx b/src/components/service-renderers/NetworkConfigRenderer.tsx new file mode 100644 index 0000000..49d7ed0 --- /dev/null +++ b/src/components/service-renderers/NetworkConfigRenderer.tsx @@ -0,0 +1,218 @@ +/** + * Network Configuration Renderer + * + * Custom renderer for network config analysis results. + * Shows adapter details, DNS analysis, and connectivity status. + */ + +import { Globe, Wifi, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface AdapterEntry { + name: string; + description: string; + type: string; + status: string; + ipv4: string; + ipv6: string; + subnetMask: string; + defaultGateway: string; + dnsServers: string[]; + dhcpEnabled: boolean; + dhcpServer: string; + macAddress: string; + adminState: string; +} + +interface DnsEntry { + server: string; + provider: string; + adapter: string; +} + +interface NetworkData { + type: 'network_config'; + totalAdapters: number; + connectedAdapters: number; + ipv6Adapters: number; + adapters: AdapterEntry[]; + dnsAnalysis: DnsEntry[]; + includeDisabled: boolean; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as NetworkData | undefined; + + if (!data || data.type !== 'network_config') return null; + + const hasConnection = data.connectedAdapters > 0; + + const getAdapterStatusIcon = (status: string) => { + if (status === 'Connected') return ; + if (status === 'Disconnected') return ; + return ; + }; + + const getAdapterStatusColor = (status: string) => { + if (status === 'Connected') return 'border-green-500/20 bg-green-500/5'; + if (status === 'Disconnected') return 'border-muted bg-muted/20'; + return 'border-yellow-500/20 bg-yellow-500/5'; + }; + + return ( + +
    + {/* Stats */} +
    +
    +

    {data.totalAdapters}

    +

    Adapters

    +
    +
    +

    {data.connectedAdapters}

    +

    Connected

    +
    +
    +

    {data.ipv6Adapters}

    +

    IPv6

    +
    +
    + + {/* Adapter Cards */} +
    + {data.adapters.map((adapter, i) => ( +
    +
    + {getAdapterStatusIcon(adapter.status)} +

    {adapter.name}

    + {adapter.status} +
    + {adapter.description && ( +

    {adapter.description}

    + )} + {adapter.status === 'Connected' && ( +
    + {adapter.ipv4 && ( + <> + IPv4 + {adapter.ipv4} + + )} + {adapter.subnetMask && ( + <> + Subnet + {adapter.subnetMask} + + )} + {adapter.defaultGateway && ( + <> + Gateway + {adapter.defaultGateway} + + )} + {adapter.dnsServers.length > 0 && ( + <> + DNS + {adapter.dnsServers.join(', ')} + + )} + DHCP + {adapter.dhcpEnabled ? 'Enabled' : 'Static'} + {adapter.macAddress && ( + <> + MAC + {adapter.macAddress} + + )} + {adapter.ipv6 && ( + <> + IPv6 + {adapter.ipv6} + + )} +
    + )} +
    + ))} +
    + + {/* DNS Analysis */} + {data.dnsAnalysis.length > 0 && ( +
    +

    DNS Servers

    +
    + {data.dnsAnalysis.map((dns, i) => ( +
    + + {dns.server} + {dns.provider} +
    + ))} +
    +
    + )} +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as NetworkData | undefined; + + if (!data || data.type !== 'network_config') return null; + + const hasConnection = data.connectedAdapters > 0; + const connectedAdapters = data.adapters.filter(a => a.status === 'Connected'); + + return ( +
    +
    +
    + +
    +
    +

    + {hasConnection ? 'โœ“ Network Connected' : 'โœ— No Network Connection'} +

    +

    + {connectedAdapters.map(a => `${a.name}: ${a.ipv4}`).join(' | ') || 'No connected adapters'} +

    +
    +
    + {hasConnection ? 'โœ“' : 'โœ—'} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function NetworkConfigRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/PingTestRenderer.tsx b/src/components/service-renderers/PingTestRenderer.tsx index c95e33e..0ac5c49 100644 --- a/src/components/service-renderers/PingTestRenderer.tsx +++ b/src/components/service-renderers/PingTestRenderer.tsx @@ -14,8 +14,8 @@ import { SignalZero, Clock, } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -68,7 +68,7 @@ function getLatencyLabel(latency: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { // Extract latency and packet loss data from findings const latencyFinding = result.findings.find( (f) => (f.data as LatencyData | undefined)?.type === 'latency' @@ -81,103 +81,83 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const packetLossData = packetLossFinding?.data as PacketLossData | undefined; return ( -
    - {/* Network Status Overview */} - - - -
    - + +
    + {/* Latency Card */} + {latencyData && ( +
    +
    + + Latency +
    +
    + {Math.round(latencyData.avgLatency)} + ms +
    +
    + Target: {latencyData.target} + + {getLatencyLabel(latencyData.avgLatency)} + +
    +
    +
    - Network Connectivity Test - - {result.success ? 'PASS' : 'FAIL'} - - - - -
    - {/* Latency Card */} - {latencyData && ( -
    -
    - - Latency -
    -
    - {Math.round(latencyData.avgLatency)} - ms -
    -
    - Target: {latencyData.target} - - {getLatencyLabel(latencyData.avgLatency)} - -
    -
    - -
    -
    - )} - - {/* Packet Loss Card */} - {packetLossData && ( -
    -
    - {packetLossData.packetLoss === 0 ? ( - - ) : ( - - )} - Packet Loss -
    -
    - {packetLossData.packetLoss} - % -
    -
    - {packetLossData.packetLoss === 0 ? ( - <> - - No packets lost - - ) : ( - <> - - Connection unstable - - )} -
    -
    - -
    -
    - )}
    - - {/* Error state */} - {result.error && ( -
    -
    - - Error -
    -

    {result.error}

    + )} + + {/* Packet Loss Card */} + {packetLossData && ( +
    +
    + {packetLossData.packetLoss === 0 ? ( + + ) : ( + + )} + Packet Loss
    - )} - - -
    +
    + {packetLossData.packetLoss} + % +
    +
    + {packetLossData.packetLoss === 0 ? ( + <> + + No packets lost + + ) : ( + <> + + Connection unstable + + )} +
    +
    + +
    +
    + )} +
    + + {/* Error state */} + {result.error && ( +
    +
    + + Error +
    +

    {result.error}

    +
    + )} + ); } diff --git a/src/components/service-renderers/RestorePointRenderer.tsx b/src/components/service-renderers/RestorePointRenderer.tsx new file mode 100644 index 0000000..231d7c5 --- /dev/null +++ b/src/components/service-renderers/RestorePointRenderer.tsx @@ -0,0 +1,119 @@ +/** + * Restore Point Service Renderer + * + * Custom renderer for the restore-point service results. + * Shows whether a system restore point was successfully created. + */ + +import { ShieldCheck, ShieldAlert, AlertTriangle } from 'lucide-react'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface RestorePointData { + type: 'restore_point_result'; + description?: string; + success: boolean; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ result, definition }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as RestorePointData | undefined; + const description = data?.description ?? 'System Restore Point'; + + return ( + + {result.success ? ( +
    +
    + +
    +

    Restore Point Created

    +

    {description}

    +
    +
    +
    + ) : ( +
    +
    + {finding?.severity === 'warning' ? ( + + ) : ( + + )} +
    +

    {finding?.title ?? 'Failed to Create Restore Point'}

    +

    {finding?.description ?? result.error}

    + {finding?.recommendation && ( +

    + {finding.recommendation} +

    + )} +
    +
    +
    + )} +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + return ( +
    +
    +
    + {result.success ? ( + + ) : ( + + )} +
    +
    +

    + System Restore Point +

    +

    + {result.success ? 'Restore Point Created' : 'Restore Point Failed'} +

    +

    + {result.success + ? 'A safety restore point was created before maintenance' + : result.error ?? 'Could not create restore point'} +

    +
    +
    + {result.success ? 'โœ“' : 'โœ—'} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function RestorePointRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') { + return ; + } + return ; +} diff --git a/src/components/service-renderers/ServiceCardWrapper.tsx b/src/components/service-renderers/ServiceCardWrapper.tsx new file mode 100644 index 0000000..e73def0 --- /dev/null +++ b/src/components/service-renderers/ServiceCardWrapper.tsx @@ -0,0 +1,110 @@ +/** + * Service Card Wrapper + * + * Standardized Card + colored header used by all service renderers. + * Provides consistent styling across the findings view. + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { getIcon } from '@/components/service/utils'; +import type { ServiceDefinition, ServiceResult } from '@/types/service'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface StatusBadge { + label: string; + color: 'green' | 'yellow' | 'red' | 'blue'; +} + +export interface ServiceCardWrapperProps { + definition: ServiceDefinition; + result: ServiceResult; + children: React.ReactNode; + /** Override the display title (defaults to definition.name) */ + title?: string; + /** Custom status badge (defaults to PASS/FAIL based on result.success) */ + statusBadge?: StatusBadge; + /** Custom content to render in the badge area (overrides statusBadge entirely) */ + badgeContent?: React.ReactNode; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const CATEGORY_GRADIENTS: Record = { + diagnostics: 'from-blue-500/10 to-cyan-500/10', + cleanup: 'from-green-500/10 to-emerald-500/10', + security: 'from-red-500/10 to-orange-500/10', + maintenance: 'from-violet-500/10 to-purple-500/10', +}; + +const CATEGORY_ICON_BG: Record = { + diagnostics: 'bg-blue-500/20 text-blue-500', + cleanup: 'bg-green-500/20 text-green-500', + security: 'bg-red-500/20 text-red-500', + maintenance: 'bg-violet-500/20 text-violet-500', +}; + +const BADGE_COLORS: Record = { + green: 'bg-green-500/10 text-green-500', + yellow: 'bg-yellow-500/10 text-yellow-500', + red: 'bg-red-500/10 text-red-500', + blue: 'bg-blue-500/10 text-blue-500', +}; + +const FALLBACK_GRADIENT = 'from-muted/30 to-muted/20'; +const FALLBACK_ICON_BG = 'bg-muted text-muted-foreground'; + +// ============================================================================= +// Component +// ============================================================================= + +export function ServiceCardWrapper({ + definition, + result, + children, + title, + statusBadge, + badgeContent, +}: ServiceCardWrapperProps) { + const Icon = getIcon(definition.icon); + const gradient = CATEGORY_GRADIENTS[definition.category] ?? FALLBACK_GRADIENT; + const iconBg = CATEGORY_ICON_BG[definition.category] ?? FALLBACK_ICON_BG; + + // Determine badge + const badge = statusBadge ?? { + label: result.success ? 'PASS' : 'FAIL', + color: result.success ? 'green' : 'red', + } satisfies StatusBadge; + + const badgeClass = BADGE_COLORS[badge.color] ?? BADGE_COLORS.blue; + + return ( + + + +
    + +
    + {title ?? definition.name} +
    + {badgeContent ?? ( + + {badge.label} + + )} + + {(result.durationMs / 1000).toFixed(1)}s + +
    +
    +
    + + {children} + +
    + ); +} diff --git a/src/components/service-renderers/SfcRenderer.tsx b/src/components/service-renderers/SfcRenderer.tsx index b5b34c4..8063b53 100644 --- a/src/components/service-renderers/SfcRenderer.tsx +++ b/src/components/service-renderers/SfcRenderer.tsx @@ -6,8 +6,8 @@ */ import { FileSearch, CheckCircle2, AlertTriangle, XCircle, RotateCcw } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { StatusBadge } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -30,7 +30,7 @@ interface SfcData { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as SfcData | undefined; @@ -42,108 +42,94 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const isRepaired = data.repairs_successful === true; const hasPending = data.pending_reboot || data.winsxs_repair_pending; - const getStatusColor = () => { - if (data.access_denied) return 'from-red-500/10 to-rose-500/10 dark:from-red-500/20 dark:to-rose-500/20'; - if (isHealthy || isRepaired) return 'from-green-500/10 to-emerald-500/10 dark:from-green-500/20 dark:to-emerald-500/20'; - if (hasPending) return 'from-yellow-500/10 to-orange-500/10 dark:from-yellow-500/20 dark:to-orange-500/20'; - return 'from-blue-500/10 to-cyan-500/10 dark:from-blue-500/20 dark:to-cyan-500/20'; - }; - - const getIconColor = () => { - if (data.access_denied) return 'bg-red-500/20 text-red-500'; - if (isHealthy || isRepaired) return 'bg-green-500/20 text-green-500'; - if (hasPending) return 'bg-yellow-500/20 text-yellow-500'; - return 'bg-blue-500/20 text-blue-500'; + const getStatusBadge = (): StatusBadge => { + if (isHealthy) return { label: 'Healthy', color: 'green' }; + if (isRepaired) return { label: 'Repaired', color: 'green' }; + if (data.repairs_successful === false) return { label: 'Issues Found', color: 'yellow' }; + if (data.access_denied) return { label: 'Access Denied', color: 'red' }; + if (hasPending) return { label: 'Pending', color: 'yellow' }; + return { label: result.success ? 'PASS' : 'FAIL', color: result.success ? 'green' : 'red' }; }; return ( - - - -
    - + + {/* Summary */} +
    +
    + {isHealthy || isRepaired ? ( + + ) : hasPending ? ( + + ) : data.access_denied ? ( + + ) : ( + + )} +
    +

    {finding?.title}

    +

    {finding?.description}

    - System File Check - {isHealthy && Healthy} - {isRepaired && Repaired} - {data.repairs_successful === false && Issues Found} - - - - {/* Summary */} -
    -
    - {isHealthy || isRepaired ? ( - - ) : hasPending ? ( - - ) : data.access_denied ? ( - +
    +
    + + {/* Status Details */} +
    +
    +

    Integrity Check

    +

    + {data.integrity_violations === false ? ( + <> + No Violations + + ) : data.integrity_violations === true ? ( + <> + Violations Found + ) : ( - + <> + Unknown + )} -

    -

    {finding?.title}

    -

    {finding?.description}

    -
    -
    +

    - - {/* Status Details */} -
    -
    -

    Integrity Check

    -

    - {data.integrity_violations === false ? ( - <> - No Violations - - ) : data.integrity_violations === true ? ( - <> - Violations Found - - ) : ( - <> - Unknown - - )} -

    -
    -
    -

    Repairs

    -

    - {!data.repairs_attempted ? ( - <> - Not Needed - - ) : data.repairs_successful === true ? ( - <> - Successful - - ) : data.repairs_successful === false ? ( - <> - Incomplete - - ) : ( - <> - In Progress - - )} -

    -
    +
    +

    Repairs

    +

    + {!data.repairs_attempted ? ( + <> + Not Needed + + ) : data.repairs_successful === true ? ( + <> + Successful + + ) : data.repairs_successful === false ? ( + <> + Incomplete + + ) : ( + <> + In Progress + + )} +

    +
    - {/* Pending Actions */} - {(data.pending_reboot || data.winsxs_repair_pending) && ( -
    -

    - {data.pending_reboot && 'โš  Reboot required to complete repairs'} - {data.winsxs_repair_pending && 'โš  Run DISM RestoreHealth before SFC'} -

    -
    - )} -
    - + {/* Pending Actions */} + {(data.pending_reboot || data.winsxs_repair_pending) && ( +
    +

    + {data.pending_reboot && 'โš  Reboot required to complete repairs'} + {data.winsxs_repair_pending && 'โš  Run DISM RestoreHealth before SFC'} +

    +
    + )} + ); } diff --git a/src/components/service-renderers/SmartctlRenderer.tsx b/src/components/service-renderers/SmartctlRenderer.tsx index d077294..51454c1 100644 --- a/src/components/service-renderers/SmartctlRenderer.tsx +++ b/src/components/service-renderers/SmartctlRenderer.tsx @@ -6,8 +6,8 @@ */ import { HardDrive, Activity, Thermometer, Clock, RotateCcw, AlertTriangle, Check, Info } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -70,7 +70,7 @@ function getWearColor(wear?: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as SmartctlData | undefined; @@ -82,131 +82,125 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const allHealthy = summary.failed === 0 && summary.warning === 0; return ( -
    - - - -
    - + +
    + {drives.map((drive, index) => ( +
    + {/* Drive Header */} +
    +
    + +
    +
    +

    {drive.modelName}

    +

    + {drive.serialNumber || drive.name} +

    +
    +
    + {drive.healthPassed === false ? ( + <> + + FAILED + + ) : drive.healthPassed === true ? ( + <> + + HEALTHY + + ) : ( + 'UNKNOWN' + )} +
    - Drive Health Report - - {summary.total} DRIVE{summary.total !== 1 ? 'S' : ''} - - - - - {drives.map((drive, index) => ( -
    - {/* Drive Header */} -
    -
    - -
    -
    -

    {drive.modelName}

    -

    - {drive.serialNumber || drive.name} -

    -
    -
    - {drive.healthPassed === false ? ( - <> - - FAILED - - ) : drive.healthPassed === true ? ( - <> - - HEALTHY - - ) : ( - 'UNKNOWN' - )} + + {/* Wear Level Progress */} + {drive.wearLevelPercent !== undefined && ( +
    +
    + Wear Level + + {drive.wearLevelPercent}% +
    +
    + )} - {/* Wear Level Progress */} - {drive.wearLevelPercent !== undefined && ( -
    -
    - Wear Level - - {drive.wearLevelPercent}% - + {/* Stats Grid */} +
    + {drive.powerOnHours !== undefined && ( +
    + +
    +

    Power On

    +

    {formatHours(drive.powerOnHours)}

    -
    )} - - {/* Stats Grid */} -
    - {drive.powerOnHours !== undefined && ( -
    - -
    -

    Power On

    -

    {formatHours(drive.powerOnHours)}

    -
    -
    - )} - {drive.powerCycles !== undefined && ( -
    - -
    -

    Cycles

    -

    {drive.powerCycles.toLocaleString()}

    -
    + {drive.powerCycles !== undefined && ( +
    + +
    +

    Cycles

    +

    {drive.powerCycles.toLocaleString()}

    - )} - {drive.temperatureCelsius !== undefined && ( -
    - 60 ? 'text-red-500' : drive.temperatureCelsius > 45 ? 'text-yellow-500' : 'text-cyan-500'}`} /> -
    -

    Temp

    -

    {drive.temperatureCelsius}ยฐC

    -
    +
    + )} + {drive.temperatureCelsius !== undefined && ( +
    + 60 ? 'text-red-500' : drive.temperatureCelsius > 45 ? 'text-yellow-500' : 'text-cyan-500'}`} /> +
    +

    Temp

    +

    {drive.temperatureCelsius}ยฐC

    - )} - {drive.dataWrittenTb !== undefined && ( -
    - -
    -

    Written

    -

    {drive.dataWrittenTb.toFixed(1)} TB

    -
    +
    + )} + {drive.dataWrittenTb !== undefined && ( +
    + +
    +

    Written

    +

    {drive.dataWrittenTb.toFixed(1)} TB

    - )} -
    - - {/* Warnings */} - {drive.mediaErrors && drive.mediaErrors > 0 && ( -
    - - {drive.mediaErrors} media error(s) detected
    )}
    - ))} - {/* Recommendation */} - {finding?.recommendation && ( -
    - - - {finding.recommendation} - -
    - )} - - -
    + {/* Warnings */} + {drive.mediaErrors && drive.mediaErrors > 0 && ( +
    + + {drive.mediaErrors} media error(s) detected +
    + )} +
    + ))} + + {/* Recommendation */} + {finding?.recommendation && ( +
    + + + {finding.recommendation} + +
    + )} +
    + ); } diff --git a/src/components/service-renderers/SpeedtestRenderer.tsx b/src/components/service-renderers/SpeedtestRenderer.tsx index 16773e9..5c04f1e 100644 --- a/src/components/service-renderers/SpeedtestRenderer.tsx +++ b/src/components/service-renderers/SpeedtestRenderer.tsx @@ -6,7 +6,7 @@ */ import { Download, Upload, Wifi, Globe, Star, Info, ExternalLink } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -52,7 +52,7 @@ function getPingColor(ping?: number): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as SpeedtestData | undefined; @@ -64,108 +64,101 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const isGood = rating >= 4; return ( -
    - - - -
    - -
    - Network Speed Test - - {[...Array(5)].map((_, i) => ( - - ))} - -
    -
    - - {/* Speed Stats */} -
    - {/* Download */} -
    -
    - - Download -
    -
    - {downloadMbps?.toFixed(1) ?? 'โ€”'} -
    -
    Mbps
    -
    - - {/* Upload */} -
    -
    - - Upload -
    -
    - {uploadMbps?.toFixed(1) ?? 'โ€”'} -
    -
    Mbps
    -
    + + {[...Array(5)].map((_, i) => ( + + ))} + + } + > + {/* Speed Stats */} +
    + {/* Download */} +
    +
    + + Download
    +
    + {downloadMbps != null ? downloadMbps.toFixed(1) : 'โ€”'} +
    +
    Mbps
    +
    - {/* Ping & Info */} -
    -
    -

    Ping

    -

    - {pingMs?.toFixed(0) ?? 'โ€”'} ms -

    -
    - {jitterMs !== undefined && ( -
    -

    Jitter

    -

    {jitterMs.toFixed(1)} ms

    -
    - )} -
    -

    ISP

    -

    {isp}

    -
    -
    -

    Server

    -

    {server}

    -
    + {/* Upload */} +
    +
    + + Upload
    +
    + {uploadMbps != null ? uploadMbps.toFixed(1) : 'โ€”'} +
    +
    Mbps
    +
    +
    - {/* Verdict */} -
    -
    - - - {verdict} - -
    - {resultUrl && ( - - View Results - - )} + {/* Ping & Info */} +
    +
    +

    Ping

    +

    + {pingMs != null ? pingMs.toFixed(0) : 'โ€”'} ms +

    +
    + {jitterMs !== undefined && ( +
    +

    Jitter

    +

    {jitterMs.toFixed(1)} ms

    + )} +
    +

    ISP

    +

    {isp}

    +
    +
    +

    Server

    +

    {server}

    +
    +
    - {/* Recommendation */} - {finding?.recommendation && ( -
    - - - {finding.recommendation} - -
    - )} - - -
    + {/* Verdict */} +
    +
    + + + {verdict} + +
    + {resultUrl && ( + + View Results + + )} +
    + + {/* Recommendation */} + {finding?.recommendation && ( +
    + + + {finding.recommendation} + +
    + )} + ); } diff --git a/src/components/service-renderers/StartupOptimizeRenderer.tsx b/src/components/service-renderers/StartupOptimizeRenderer.tsx new file mode 100644 index 0000000..a660202 --- /dev/null +++ b/src/components/service-renderers/StartupOptimizeRenderer.tsx @@ -0,0 +1,317 @@ +/** + * Startup Optimizer Renderer + * + * Custom renderer for startup optimization results. + * Shows classified startup items with filter and disable highlights. + */ + +import { useState } from 'react'; +import { Rocket, Search, Sparkles } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface StartupItemEntry { + id: string; + name: string; + command: string; + source: string; + sourceLocation: string; + enabled: boolean; + publisher: string | null; + description: string | null; + classification: 'essential' | 'useful' | 'unnecessary'; + disabledThisRun: boolean; +} + +interface StartupOptimizeData { + type: 'startup_optimize'; + mode: 'report' | 'disable'; + aiPowered: boolean; + totalItems: number; + essentialCount: number; + usefulCount: number; + unnecessaryCount: number; + disabledThisRun: string[]; + failedItems: { name: string; error: string }[]; + items: StartupItemEntry[]; +} + +type FilterType = 'all' | 'essential' | 'useful' | 'unnecessary'; + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as StartupOptimizeData | undefined; + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('all'); + + if (!data || data.type !== 'startup_optimize') return null; + + const hasUnnecessary = data.unnecessaryCount > 0; + const disabledCount = data.disabledThisRun.length; + + const filteredItems = data.items.filter((item) => { + if (filter !== 'all' && item.classification !== filter) return false; + if (search) { + const q = search.toLowerCase(); + return ( + item.name.toLowerCase().includes(q) || + item.command.toLowerCase().includes(q) || + (item.publisher ?? '').toLowerCase().includes(q) + ); + } + return true; + }); + + const classificationBadge = (c: string, disabledThisRun: boolean) => { + if (disabledThisRun) { + return ( + + Disabled + + ); + } + switch (c) { + case 'essential': + return ( + + Essential + + ); + case 'unnecessary': + return ( + + Unnecessary + + ); + default: + return ( + + Useful + + ); + } + }; + + const sourceName = (source: string) => { + switch (source) { + case 'registryCurrentUser': + return 'Registry (User)'; + case 'registryLocalMachine': + return 'Registry (Machine)'; + case 'startupFolderUser': + return 'Startup Folder (User)'; + case 'startupFolderAllUsers': + return 'Startup Folder (All)'; + case 'taskScheduler': + return 'Task Scheduler'; + default: + return source; + } + }; + + const badgeContent = ( +
    + {data.aiPowered && ( + + + AI + + )} + + {data.mode === 'disable' + ? `Disabled ${disabledCount} Item${disabledCount !== 1 ? 's' : ''}` + : 'Report Only'} + +
    + ); + + return ( + +
    + {/* Stats */} +
    +
    +

    {data.totalItems}

    +

    Total

    +
    +
    +

    + {data.essentialCount} +

    +

    Essential

    +
    +
    +

    {data.usefulCount}

    +

    Useful

    +
    +
    0 + ? 'bg-red-500/10 border-red-500/20' + : 'bg-muted/30' + }`} + > +

    0 ? 'text-red-500' : '' + }`} + > + {data.unnecessaryCount} +

    +

    Unnecessary

    +
    +
    + + {/* Filter + Search */} +
    + {(['all', 'essential', 'useful', 'unnecessary'] as FilterType[]).map( + (f) => ( + + ), + )} +
    + + {data.items.length > 5 && ( +
    + + setSearch(e.target.value)} + className="pl-9" + /> +
    + )} + + {/* Item List */} +
    + {filteredItems.map((item, i) => ( +
    +
    +
    +

    {item.name}

    +

    + {sourceName(item.source)} + {item.publisher ? ` ยท ${item.publisher}` : ''} + {!item.enabled && !item.disabledThisRun ? ' ยท Disabled' : ''} +

    +
    + {classificationBadge(item.classification, item.disabledThisRun)} +
    +
    + ))} + {filteredItems.length === 0 && ( +

    + {search ? 'No matching items' : 'No items to display'} +

    + )} +
    + + {/* Hint */} + {data.mode === 'report' && data.unnecessaryCount > 0 && ( +

    + Re-run with "Disable Unnecessary Items" enabled to + automatically disable {data.unnecessaryCount} item + {data.unnecessaryCount !== 1 ? 's' : ''}. +

    + )} +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const finding = result.findings[0]; + const data = finding?.data as StartupOptimizeData | undefined; + + if (!data || data.type !== 'startup_optimize') return null; + + const isGood = data.unnecessaryCount === 0; + const disabledCount = data.disabledThisRun.length; + + return ( +
    +
    +
    + +
    +
    +

    + {isGood + ? 'โœ“ No Unnecessary Startup Items' + : data.mode === 'disable' + ? `โœ“ Disabled ${disabledCount} Unnecessary Startup Item${disabledCount !== 1 ? 's' : ''}` + : `โš  ${data.unnecessaryCount} Unnecessary Startup Item${data.unnecessaryCount !== 1 ? 's' : ''} Found`} +

    +

    + {data.totalItems} items analyzed{data.aiPowered ? ' by AI' : ''}: {data.essentialCount} essential,{' '} + {data.usefulCount} useful, {data.unnecessaryCount} unnecessary +

    + {data.disabledThisRun.length > 0 && ( +

    + Disabled: {data.disabledThisRun.join(', ')} +

    + )} +
    +
    + {isGood || data.mode === 'disable' ? 'โœ“' : 'โš '} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function StartupOptimizeRenderer(props: ServiceRendererProps) { + if (props.variant === 'customer') return ; + return ; +} diff --git a/src/components/service-renderers/StingerRenderer.tsx b/src/components/service-renderers/StingerRenderer.tsx index cade954..fb53ea2 100644 --- a/src/components/service-renderers/StingerRenderer.tsx +++ b/src/components/service-renderers/StingerRenderer.tsx @@ -15,8 +15,8 @@ import { Trash2, } from 'lucide-react'; import { RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, type ChartConfig } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -90,7 +90,7 @@ function getStatusInfo(data: StingerResultData): { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings.find( (f) => (f.data as StingerResultData | undefined)?.type === 'stinger_result' ); @@ -99,27 +99,14 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!data) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - Antivirus Scan (Stinger) - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Scan Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete antivirus scan'} -

    -
    -
    -
    + +
    +

    {errorFinding?.title || 'Scan Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete antivirus scan'} +

    +
    +
    ); } @@ -131,132 +118,115 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const chartData = [{ name: 'status', value: isClean ? 100 : data.action === 'delete' ? 70 : 30, fill: status.color }]; const chartConfig: ChartConfig = { value: { label: 'Status', color: status.color } }; + const statusBadge = { + label: status.label, + color: (isClean ? 'green' : data.action === 'delete' ? 'yellow' : 'red') as 'green' | 'yellow' | 'red', + }; + return ( -
    - - - -
    - {isClean ? ( - - ) : ( - - )} + +
    + {/* Status Chart */} +
    +
    + + + + + + +
    + {status.icon}
    - Antivirus Scan (Stinger) - - {status.label} - - - - -
    - {/* Status Chart */} -
    -
    - - - - - - -
    - {status.icon} -
    -
    -
    -

    - {isClean ? 'No Threats' : `${infectionCount} Threat${infectionCount !== 1 ? 's' : ''}`} -

    -

    - {totalFiles.toLocaleString()} files scanned -

    -
    +
    +
    +

    + {isClean ? 'No Threats' : `${infectionCount} Threat${infectionCount !== 1 ? 's' : ''}`} +

    +

    + {totalFiles.toLocaleString()} files scanned +

    +
    +
    + + {/* Stats */} +
    +
    + +
    +

    Files Scanned

    +

    {totalFiles.toLocaleString()}

    +
    - {/* Stats */} -
    -
    - -
    -

    Files Scanned

    -

    {totalFiles.toLocaleString()}

    -
    -
    +
    + 0 ? 'text-red-500' : 'text-green-500'}`} /> +
    +

    Threats Found

    +

    0 ? 'text-red-500' : ''}`}> + {infectionCount} +

    +
    +
    -
    - 0 ? 'text-red-500' : 'text-green-500'}`} /> -
    -

    Threats Found

    -

    0 ? 'text-red-500' : ''}`}> - {infectionCount} -

    -
    + {data.action === 'delete' && infectionCount > 0 && ( +
    + +
    +

    Action Taken

    +

    Deleted

    - - {data.action === 'delete' && infectionCount > 0 && ( -
    - -
    -

    Action Taken

    -

    Deleted

    -
    -
    - )} - - {data.version && ( -
    - -
    -

    Engine Version

    -

    {data.version}

    -
    -
    - )}
    -
    + )} - {/* Infection List */} - {data.infections.length > 0 && ( -
    -

    Detected Threats

    -
    - {data.infections.map((infection, idx) => ( -
    -
    -
    -

    {infection.threatName}

    -

    - {infection.filePath} -

    -
    - {data.action === 'delete' && ( - - Removed - - )} -
    -
    - ))} + {data.version && ( +
    + +
    +

    Engine Version

    +

    {data.version}

    )} +
    +
    - {/* Scan Info */} -
    - Mode: {data.action === 'delete' ? 'Delete Threats' : 'Report Only'} - {data.includePups && โ€ข PUP Detection: On} - {data.scanPath && โ€ข Path: {data.scanPath}} + {/* Infection List */} + {data.infections.length > 0 && ( +
    +

    Detected Threats

    +
    + {data.infections.map((infection, idx) => ( +
    +
    +
    +

    {infection.threatName}

    +

    + {infection.filePath} +

    +
    + {data.action === 'delete' && ( + + Removed + + )} +
    +
    + ))}
    - - -
    +
    + )} + + {/* Scan Info */} +
    + Mode: {data.action === 'delete' ? 'Delete Threats' : 'Report Only'} + {data.includePups && โ€ข PUP Detection: On} + {data.scanPath && โ€ข Path: {data.scanPath}} +
    + ); } diff --git a/src/components/service-renderers/UsbStabilityRenderer.tsx b/src/components/service-renderers/UsbStabilityRenderer.tsx new file mode 100644 index 0000000..574fa5d --- /dev/null +++ b/src/components/service-renderers/UsbStabilityRenderer.tsx @@ -0,0 +1,403 @@ +/** + * USB Stability Test Service Renderer + * + * Custom renderer for the usb-stability service results. + * Shows speed benchmarks, integrity status, random I/O latency, + * and fake-drive detection results. + */ + +import { + Usb, + AlertTriangle, + ArrowDown, + ArrowUp, + Zap, + HardDrive, + ShieldCheck, + ShieldAlert, +} from 'lucide-react'; +import { Progress } from '@/components/ui/progress'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; +import type { ServiceRendererProps } from './index'; + +// ============================================================================= +// Types +// ============================================================================= + +interface UsbSummaryData { + type: 'usb_summary'; + drivePath: string; + volumeLabel: string; + fileSystem: string; + totalSpaceBytes: number; + availableSpaceBytes: number; + testSizeMb: number; + intensity: string; + writeSpeedMbps: number; + readSpeedMbps: number; + writeDurationSecs: number; + readDurationSecs: number; + integrityPass: boolean; + integrityErrors: number; + integrityChecked: boolean; + overallStatus: string; + totalDurationSecs: number; +} + +interface UsbSpeedData { + type: 'usb_write_speed' | 'usb_read_speed'; + speedMbps: number; + rating: string; +} + +interface UsbIntegrityData { + type: 'usb_integrity'; + pass: boolean; + errorCount: number; + firstErrorOffset: number | null; + bytesVerified: number; +} + +interface UsbRandomIoData { + type: 'usb_random_io'; + avgMs: number; + minMs: number; + maxMs: number; + iterations: number; + blockSize: number; +} + +interface UsbCapacityData { + type: 'usb_capacity'; + pass: boolean; + expectedMb: number; + actualUsedMb: number; + discrepancyMb: number; + fakeDriveSuspected?: boolean; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function getSpeedColor(mbps: number): string { + if (mbps >= 100) return 'text-blue-500'; + if (mbps >= 50) return 'text-green-500'; + if (mbps >= 20) return 'text-yellow-500'; + if (mbps >= 5) return 'text-orange-500'; + return 'text-red-500'; +} + +function getSpeedBg(mbps: number): string { + if (mbps >= 100) return 'bg-blue-500/10'; + if (mbps >= 50) return 'bg-green-500/10'; + if (mbps >= 20) return 'bg-yellow-500/10'; + if (mbps >= 5) return 'bg-orange-500/10'; + return 'bg-red-500/10'; +} + +function getSpeedBarColor(mbps: number): string { + if (mbps >= 100) return 'hsl(217, 91%, 60%)'; // blue + if (mbps >= 50) return 'hsl(142, 71%, 45%)'; // green + if (mbps >= 20) return 'hsl(48, 96%, 53%)'; // yellow + if (mbps >= 5) return 'hsl(25, 95%, 53%)'; // orange + return 'hsl(0, 84%, 60%)'; // red +} + +function getSpeedLabel(mbps: number): string { + if (mbps >= 100) return 'USB 3.0+'; + if (mbps >= 50) return 'USB 3.0'; + if (mbps >= 20) return 'USB 2.0'; + if (mbps >= 5) return 'Slow'; + return 'Very Slow'; +} + +function formatBytes(bytes: number): string { + const gb = bytes / 1_073_741_824; + if (gb >= 1000) return `${(gb / 1024).toFixed(1)} TB`; + if (gb >= 1) return `${gb.toFixed(1)} GB`; + return `${(bytes / 1_048_576).toFixed(0)} MB`; +} + +function getStatusBadge(status: string) { + const colors: Record = { + PASS: 'bg-green-500/10 text-green-500', + 'PASS WITH WARNINGS': 'bg-yellow-500/10 text-yellow-500', + 'ISSUES DETECTED': 'bg-orange-500/10 text-orange-500', + FAIL: 'bg-red-500/10 text-red-500', + }; + return colors[status] || 'bg-muted text-muted-foreground'; +} + +// ============================================================================= +// Findings Variant +// ============================================================================= + +function FindingsRenderer({ definition, result }: ServiceRendererProps) { + const summaryFinding = result.findings.find( + (f) => (f.data as UsbSummaryData | undefined)?.type === 'usb_summary' + ); + const summary = summaryFinding?.data as UsbSummaryData | undefined; + + const writeData = result.findings.find( + (f) => (f.data as UsbSpeedData | undefined)?.type === 'usb_write_speed' + )?.data as UsbSpeedData | undefined; + + const readData = result.findings.find( + (f) => (f.data as UsbSpeedData | undefined)?.type === 'usb_read_speed' + )?.data as UsbSpeedData | undefined; + + const integrityData = result.findings.find( + (f) => (f.data as UsbIntegrityData | undefined)?.type === 'usb_integrity' + )?.data as UsbIntegrityData | undefined; + + const randomIoData = result.findings.find( + (f) => (f.data as UsbRandomIoData | undefined)?.type === 'usb_random_io' + )?.data as UsbRandomIoData | undefined; + + const capacityData = result.findings.find( + (f) => (f.data as UsbCapacityData | undefined)?.type === 'usb_capacity' + )?.data as UsbCapacityData | undefined; + + if (!summary) return null; + + // Speed bar max for visual scale (cap at 200 MB/s) + const speedMax = 200; + + const statusBadge = summary.overallStatus === 'PASS' + ? { label: 'PASS', color: 'green' as const } + : summary.overallStatus === 'PASS WITH WARNINGS' + ? { label: 'PASS WITH WARNINGS', color: 'yellow' as const } + : summary.overallStatus === 'ISSUES DETECTED' + ? { label: 'ISSUES DETECTED', color: 'yellow' as const } + : { label: 'FAIL', color: 'red' as const }; + + return ( + +
    + {/* Drive Info */} +

    + {summary.drivePath} โ€” {summary.volumeLabel} ({formatBytes(summary.totalSpaceBytes)}, {summary.fileSystem}) +

    + + {/* Fake Drive Warning */} + {capacityData && !capacityData.pass && ( +
    + +
    +

    โš  Fake Drive Suspected

    +

    + Capacity mismatch detected โ€” this drive may have less real storage than advertised. + Do NOT use for important data. +

    +
    +
    + )} + + {/* Speed Benchmarks */} +
    + {/* Write Speed */} +
    +
    + + Write Speed + + {writeData?.rating || getSpeedLabel(summary.writeSpeedMbps)} + +
    +

    + {summary.writeSpeedMbps.toFixed(1)} MB/s +

    + +

    + {summary.testSizeMb} MB in {summary.writeDurationSecs.toFixed(1)}s +

    +
    + + {/* Read Speed */} +
    +
    + + Read Speed + + {readData?.rating || getSpeedLabel(summary.readSpeedMbps)} + +
    +

    + {summary.readSpeedMbps.toFixed(1)} MB/s +

    + +

    + {summary.testSizeMb} MB in {summary.readDurationSecs.toFixed(1)}s +

    +
    +
    + + {/* Integrity & Random I/O */} +
    + {/* Integrity Check */} + {integrityData && ( +
    +
    + {integrityData.pass ? ( + + ) : ( + + )} + Data Integrity +
    +

    + {integrityData.pass ? 'PASS' : 'FAIL'} +

    +

    + {integrityData.pass + ? `${formatBytes(integrityData.bytesVerified)} verified โ€” zero corruption` + : `${integrityData.errorCount.toLocaleString()} errors detected`} +

    +
    + )} + + {/* Not checked */} + {!integrityData && summary.integrityChecked === false && ( +
    +
    + + Data Integrity +
    +

    Skipped

    +

    + Enable "Data Integrity Check" option for verification +

    +
    + )} + + {/* Random I/O */} + {randomIoData && ( +
    +
    + + Random I/O Latency +
    +

    + {randomIoData.avgMs.toFixed(2)} ms avg +

    +
    + Min: {randomIoData.minMs.toFixed(2)}ms + Max: {randomIoData.maxMs.toFixed(2)}ms +
    +

    + {randomIoData.iterations} ร— {(randomIoData.blockSize / 1024).toFixed(0)}KB random reads +

    +
    + )} +
    + + {/* Capacity Check */} + {capacityData && ( +
    +
    + + Capacity Verification + + {capacityData.pass ? 'PASS' : 'FAILED'} + +
    +

    + Expected ~{capacityData.expectedMb} MB used, actual ~{capacityData.actualUsedMb} MB + {capacityData.discrepancyMb > 0 && ` (ยฑ${capacityData.discrepancyMb} MB discrepancy)`} +

    +
    + )} + + {/* Test Info */} +
    + Intensity: {summary.intensity} + Test size: {summary.testSizeMb} MB + Duration: {summary.totalDurationSecs.toFixed(1)}s +
    +
    +
    + ); +} + +// ============================================================================= +// Customer Print Variant +// ============================================================================= + +function CustomerRenderer({ result }: ServiceRendererProps) { + const summaryFinding = result.findings.find( + (f) => (f.data as UsbSummaryData | undefined)?.type === 'usb_summary' + ); + const summary = summaryFinding?.data as UsbSummaryData | undefined; + + if (!summary) return null; + + const passed = summary.overallStatus === 'PASS'; + const hasWarnings = summary.overallStatus === 'PASS WITH WARNINGS'; + + return ( +
    +
    +
    + +
    +
    +

    + USB Drive Test +

    +

    + {summary.volumeLabel} ({summary.drivePath}) +

    +
    +

    + Storage: {formatBytes(summary.totalSpaceBytes)} ({summary.fileSystem}) +

    +

    + Write Speed: {summary.writeSpeedMbps.toFixed(1)} MB/s โ€” Read Speed: {summary.readSpeedMbps.toFixed(1)} MB/s +

    + {summary.integrityChecked && ( +

    + Data Integrity: {summary.integrityPass ? 'โœ“ Verified' : `โœ— ${summary.integrityErrors} errors found`} +

    + )} +
    + {!passed && !hasWarnings && ( +

    + โš  Issues detected โ€” this drive may need to be replaced +

    + )} + {hasWarnings && ( +

    + โš  Minor issues detected โ€” drive is functional but may have reduced performance +

    + )} +
    +
    + {passed ? 'โœ“' : hasWarnings ? 'โš ' : 'โœ—'} +
    +
    +
    + ); +} + +// ============================================================================= +// Main Renderer +// ============================================================================= + +export function UsbStabilityRenderer(props: ServiceRendererProps) { + const { variant } = props; + + if (variant === 'customer') { + return ; + } + + return ; +} diff --git a/src/components/service-renderers/WhyNotWin11Renderer.tsx b/src/components/service-renderers/WhyNotWin11Renderer.tsx index 067bc4a..2922da4 100644 --- a/src/components/service-renderers/WhyNotWin11Renderer.tsx +++ b/src/components/service-renderers/WhyNotWin11Renderer.tsx @@ -6,7 +6,7 @@ */ import { MonitorCheck, CircleCheck, CircleX, Info, AlertTriangle } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -26,7 +26,7 @@ interface WhyNotWin11Data { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as WhyNotWin11Data | undefined; @@ -40,71 +40,61 @@ function FindingsRenderer({ result }: ServiceRendererProps) { return a ? 1 : -1; // Failed checks first }); + const statusBadge = { + label: ready ? 'COMPATIBLE' : 'NOT COMPATIBLE', + color: (ready ? 'green' : 'yellow') as 'green' | 'yellow', + }; + return ( -
    - - - -
    - -
    - Windows 11 Compatibility - - {ready ? 'COMPATIBLE' : 'NOT COMPATIBLE'} + + {/* Summary */} +
    + {ready ? ( + <> + + This PC meets all Windows 11 requirements + + ) : ( + <> + + + This PC does not meet {failingChecks.length} Windows 11 requirement(s) - - - - {/* Summary */} -
    - {ready ? ( - <> - - This PC meets all Windows 11 requirements - + + )} +
    + + {/* Checks Grid */} +
    + {sortedChecks.map(([name, passed]) => ( +
    + {passed ? ( + ) : ( - <> - - - This PC does not meet {failingChecks.length} Windows 11 requirement(s) - - + )} + + {name} +
    + ))} +
    - {/* Checks Grid */} -
    - {sortedChecks.map(([name, passed]) => ( -
    - {passed ? ( - - ) : ( - - )} - - {name} - -
    - ))} + {/* Recommendation */} + {finding?.recommendation && ( +
    + +
    + {finding.recommendation}
    - - {/* Recommendation */} - {finding?.recommendation && ( -
    - -
    - {finding.recommendation} -
    -
    - )} - - -
    +
    + )} + ); } diff --git a/src/components/service-renderers/WindowsUpdateRenderer.tsx b/src/components/service-renderers/WindowsUpdateRenderer.tsx index 753c2b0..5d459f8 100644 --- a/src/components/service-renderers/WindowsUpdateRenderer.tsx +++ b/src/components/service-renderers/WindowsUpdateRenderer.tsx @@ -6,9 +6,8 @@ */ import { CloudDownload, CheckCircle2, XCircle, RotateCcw, Package } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -38,7 +37,7 @@ interface WindowsUpdateData { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { const finding = result.findings[0]; const data = finding?.data as WindowsUpdateData | undefined; @@ -50,91 +49,77 @@ function FindingsRenderer({ result }: ServiceRendererProps) { const hasInstalled = data.installed_count > 0; const hasFailed = data.failed_count > 0; - const getStatusColor = () => { - if (hasFailed) return 'from-yellow-500/10 to-orange-500/10 dark:from-yellow-500/20 dark:to-orange-500/20'; - if (hasInstalled || isUpToDate) return 'from-green-500/10 to-emerald-500/10 dark:from-green-500/20 dark:to-emerald-500/20'; - return 'from-blue-500/10 to-cyan-500/10 dark:from-blue-500/20 dark:to-cyan-500/20'; + const statusBadge = { + label: isUpToDate ? 'UP TO DATE' : hasInstalled && !hasFailed ? `${data.installed_count} INSTALLED` : hasFailed ? `${data.failed_count} FAILED` : 'CHECKED', + color: (hasFailed ? 'yellow' : 'green') as 'yellow' | 'green', }; return ( - - - -
    - -
    - Windows Update - {isUpToDate && Up to Date} - {hasInstalled && !hasFailed && {data.installed_count} Installed} - {hasFailed && {data.failed_count} Failed} -
    -
    - - {/* Stats Grid */} -
    -
    - -

    {data.available_count}

    -

    Available

    -
    -
    - -

    {data.installed_count}

    -

    Installed

    -
    -
    - -

    {data.failed_count}

    -

    Failed

    -
    -
    - -

    {data.reboot_required ? 'Yes' : 'No'}

    -

    Reboot

    -
    + + {/* Stats Grid */} +
    +
    + +

    {data.available_count}

    +

    Available

    +
    +
    + +

    {data.installed_count}

    +

    Installed

    +
    +
    + +

    {data.failed_count}

    +

    Failed

    +
    + +

    {data.reboot_required ? 'Yes' : 'No'}

    +

    Reboot

    +
    +
    - {/* Update List */} - {data.updates && data.updates.length > 0 && ( -
    -

    Available Updates

    - -
    - {data.updates.slice(0, 10).map((update, index) => ( -
    - -
    -

    {update.Title}

    -

    - {update.KB && `KB${update.KB}`} - {update.IsDriver && ' โ€ข Driver'} -

    -
    + {/* Update List */} + {data.updates && data.updates.length > 0 && ( +
    +

    Available Updates

    + +
    + {data.updates.slice(0, 10).map((update, index) => ( +
    + +
    +

    {update.Title}

    +

    + {update.KB && `KB${update.KB}`} + {update.IsDriver && ' โ€ข Driver'} +

    - ))} - {data.updates.length > 10 && ( -

    - +{data.updates.length - 10} more updates -

    - )} -
    - -
    - )} - - {/* Reboot Warning */} - {data.reboot_required && ( -
    -
    - -

    - Restart required to complete update installation -

    +
    + ))} + {data.updates.length > 10 && ( +

    + +{data.updates.length - 10} more updates +

    + )}
    +
    +
    + )} + + {/* Reboot Warning */} + {data.reboot_required && ( +
    +
    + +

    + Restart required to complete update installation +

    - )} - - +
    + )} + ); } diff --git a/src/components/service-renderers/WinsatRenderer.tsx b/src/components/service-renderers/WinsatRenderer.tsx index 37210cf..ebbdf42 100644 --- a/src/components/service-renderers/WinsatRenderer.tsx +++ b/src/components/service-renderers/WinsatRenderer.tsx @@ -7,13 +7,13 @@ import { Gauge, Zap, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { Bar, BarChart, XAxis, YAxis, Cell, LabelList } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig, } from '@/components/ui/chart'; +import { ServiceCardWrapper } from './ServiceCardWrapper'; import type { ServiceRendererProps } from './index'; // ============================================================================= @@ -66,7 +66,7 @@ function getRatingColor(rating: string): string { // Findings Variant // ============================================================================= -function FindingsRenderer({ result }: ServiceRendererProps) { +function FindingsRenderer({ result, definition }: ServiceRendererProps) { // Extract winsat data from findings const winsatFinding = result.findings.find( (f) => (f.data as WinsatData | undefined)?.type === 'winsat_summary' @@ -77,30 +77,17 @@ function FindingsRenderer({ result }: ServiceRendererProps) { if (!winsatData) { const errorFinding = result.findings.find((f) => f.severity === 'error'); return ( - - - -
    - -
    - Disk Performance Benchmark - - FAILED - -
    -
    - -
    -

    {errorFinding?.title || 'Benchmark Failed'}

    -

    - {errorFinding?.description || result.error || 'Could not complete benchmark'} -

    - {errorFinding?.recommendation && ( -

    ๐Ÿ’ก {errorFinding.recommendation}

    - )} -
    -
    -
    + +
    +

    {errorFinding?.title || 'Benchmark Failed'}

    +

    + {errorFinding?.description || result.error || 'Could not complete benchmark'} +

    + {errorFinding?.recommendation && ( +

    ๐Ÿ’ก {errorFinding.recommendation}

    + )} +
    +
    ); } @@ -121,93 +108,83 @@ function FindingsRenderer({ result }: ServiceRendererProps) { }, }; + const statusBadge = { + label: rating.toUpperCase(), + color: (['Excellent', 'Good'].includes(rating) ? 'green' : ['Average'].includes(rating) ? 'blue' : rating === 'Below Average' ? 'yellow' : 'red') as 'green' | 'blue' | 'yellow' | 'red', + }; + return ( -
    - - - -
    - -
    - Disk Performance Benchmark - - {rating.toUpperCase()} - -
    -
    - - {/* Summary Stats */} -
    -
    -

    Drive

    -

    {drive}:

    -
    -
    -

    Avg Speed

    -

    - {avgSpeed.toFixed(0)} - MB/s -

    -
    -
    -

    Rating

    -

    - {rating} -

    -
    -
    + + {/* Summary Stats */} +
    +
    +

    Drive

    +

    {drive}:

    +
    +
    +

    Avg Speed

    +

    + {avgSpeed.toFixed(0)} + MB/s +

    +
    +
    +

    Rating

    +

    + {rating} +

    +
    +
    - {/* Bar Chart */} - {chartData.length > 0 && ( - - - - - `${value} MB/s`} hideLabel />} - /> - - {chartData.map((entry, index) => ( - - ))} - `${value.toFixed(0)} MB/s`} - className="fill-foreground" - fontSize={12} - /> - - - - )} + {/* Bar Chart */} + {chartData.length > 0 && ( + + + + + `${value} MB/s`} hideLabel />} + /> + + {chartData.map((entry, index) => ( + + ))} + `${value.toFixed(0)} MB/s`} + className="fill-foreground" + fontSize={12} + /> + + + + )} - {/* Performance Note */} -
    - - - {avgSpeed >= 300 - ? 'SSD-level performance detected. Your disk is fast!' - : avgSpeed >= 100 - ? 'Decent performance. Consider upgrading to SSD for better speeds.' - : 'Slow disk detected. An SSD upgrade would significantly improve performance.'} - -
    -
    -
    -
    + {/* Performance Note */} +
    + + + {avgSpeed >= 300 + ? 'SSD-level performance detected. Your disk is fast!' + : avgSpeed >= 100 + ? 'Decent performance. Consider upgrading to SSD for better speeds.' + : 'Slow disk detected. An SSD upgrade would significantly improve performance.'} + +
    + ); } diff --git a/src/components/service-renderers/index.ts b/src/components/service-renderers/index.ts index 4152438..428acda 100644 --- a/src/components/service-renderers/index.ts +++ b/src/components/service-renderers/index.ts @@ -15,7 +15,7 @@ import type { ServiceResult, ServiceDefinition } from '@/types/service'; import { PingTestRenderer } from './PingTestRenderer'; import { DiskSpaceRenderer } from './DiskSpaceRenderer'; import { WinsatRenderer } from './WinsatRenderer'; -import { BatteryInfoRenderer } from './BatteryInfoRenderer'; + import { KvrtScanRenderer } from './KvrtScanRenderer'; import { AdwCleanerRenderer } from './AdwCleanerRenderer'; import { WhyNotWin11Renderer } from './WhyNotWin11Renderer'; @@ -29,6 +29,14 @@ import { WindowsUpdateRenderer } from './WindowsUpdateRenderer'; import { ChkdskRenderer } from './ChkdskRenderer'; import { FurmarkRenderer } from './FurmarkRenderer'; import { StingerRenderer } from './StingerRenderer'; +import { EnergyReportRenderer } from './EnergyReportRenderer'; +import { BatteryReportRenderer } from './BatteryReportRenderer'; +import { DriverAuditRenderer } from './DriverAuditRenderer'; +import { InstalledSoftwareRenderer } from './InstalledSoftwareRenderer'; +import { NetworkConfigRenderer } from './NetworkConfigRenderer'; +import { UsbStabilityRenderer } from './UsbStabilityRenderer'; +import { StartupOptimizeRenderer } from './StartupOptimizeRenderer'; +import { RestorePointRenderer } from './RestorePointRenderer'; // ============================================================================= // Types @@ -56,7 +64,7 @@ export const SERVICE_RENDERERS: Partial> = { 'ping-test': PingTestRenderer, 'disk-space': DiskSpaceRenderer, 'winsat': WinsatRenderer, - 'battery-info': BatteryInfoRenderer, + 'kvrt-scan': KvrtScanRenderer, 'adwcleaner': AdwCleanerRenderer, 'whynotwin11': WhyNotWin11Renderer, @@ -70,6 +78,14 @@ export const SERVICE_RENDERERS: Partial> = { 'chkdsk': ChkdskRenderer, 'furmark': FurmarkRenderer, 'stinger': StingerRenderer, + 'energy-report': EnergyReportRenderer, + 'battery-report': BatteryReportRenderer, + 'driver-audit': DriverAuditRenderer, + 'installed-software': InstalledSoftwareRenderer, + 'network-config': NetworkConfigRenderer, + 'usb-stability': UsbStabilityRenderer, + 'startup-optimize': StartupOptimizeRenderer, + 'restore-point': RestorePointRenderer, }; /** @@ -86,3 +102,6 @@ export function getServiceRenderer(serviceId: string): ServiceRenderer | undefin export function hasCustomRenderer(serviceId: string): boolean { return serviceId in SERVICE_RENDERERS; } + +export { ServiceCardWrapper } from './ServiceCardWrapper'; +export type { ServiceCardWrapperProps, StatusBadge } from './ServiceCardWrapper'; diff --git a/src/components/service-report-view.tsx b/src/components/service-report-view.tsx index 4bdf0e2..38c5e3d 100644 --- a/src/components/service-report-view.tsx +++ b/src/components/service-report-view.tsx @@ -20,6 +20,11 @@ import { Printer, Users, ChevronRight, + Bot, + Heart, + Sparkles, + Loader2, + RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -33,24 +38,10 @@ import type { FindingSeverity, } from '@/types/service'; import type { BusinessSettings } from '@/types/settings'; -import { getServiceRenderer } from '@/components/service-renderers'; +import { getServiceRenderer, ServiceCardWrapper } from '@/components/service-renderers'; +import { getIcon } from '@/components/service/utils'; import { useSettings } from '@/components/settings-context'; - -// ============================================================================= -// Icon Mapping -// ============================================================================= - -const ICON_MAP: Record> = { - stethoscope: Info, - wrench: Wrench, - 'shield-check': CheckCircle2, - 'settings-2': Wrench, - wifi: Info, -}; - -function getIcon(iconName: string) { - return ICON_MAP[iconName] || Wrench; -} +import { isAiConfigured, aiSummarizeReport } from '@/lib/ai-features'; // ============================================================================= // Printable Report Component @@ -377,14 +368,52 @@ export function ServiceReportView({ const printDetailedRef = useRef(null); const printCustomerRef = useRef(null); + // AI Summary state + const [localReport, setLocalReport] = useState(report); + const [aiSummaryLoading, setAiSummaryLoading] = useState(false); + const [aiSummaryError, setAiSummaryError] = useState(null); + + // Keep localReport in sync with prop changes + useEffect(() => { + setLocalReport(report); + }, [report]); + + const handleGenerateAiSummary = async () => { + if (!isAiConfigured(settings.agent)) { + setAiSummaryError('AI not configured. Set up a provider in Settings \u2192 AI Agent.'); + return; + } + setAiSummaryLoading(true); + setAiSummaryError(null); + + try { + const result = await aiSummarizeReport(localReport, definitions, settings.agent); + + // Persist to backend + await invoke('set_report_summary', { report_id: localReport.id, summary: result.summary }); + await invoke('set_report_health_score', { report_id: localReport.id, score: result.healthScore }); + + // Update local state + setLocalReport((prev) => ({ + ...prev, + agentSummary: result.summary, + healthScore: result.healthScore, + })); + } catch (e) { + setAiSummaryError(e instanceof Error ? e.message : String(e)); + } finally { + setAiSummaryLoading(false); + } + }; + const handlePrintDetailed = useReactToPrint({ contentRef: printDetailedRef, - documentTitle: `Service Report - ${new Date(report.startedAt).toLocaleDateString()}`, + documentTitle: `Service Report - ${new Date(localReport.startedAt).toLocaleDateString()}`, }); const handlePrintCustomer = useReactToPrint({ contentRef: printCustomerRef, - documentTitle: `System Health Report - ${new Date(report.startedAt).toLocaleDateString()}`, + documentTitle: `System Health Report - ${new Date(localReport.startedAt).toLocaleDateString()}`, }); const severityIcons: Record> = { @@ -403,7 +432,7 @@ export function ServiceReportView({ critical: 'text-purple-500 bg-purple-500/10 border-purple-500/20', }; - const allFindings = report.results.flatMap((r) => + const allFindings = localReport.results.flatMap((r) => r.findings.map((f) => ({ ...f, serviceId: r.serviceId, @@ -415,17 +444,17 @@ export function ServiceReportView({ (f) => f.severity === 'warning' || f.severity === 'error' || f.severity === 'critical' ); - const totalDuration = report.totalDurationMs ? (report.totalDurationMs / 1000).toFixed(1) : '?'; - const successCount = report.results.filter((r) => r.success).length; - const totalCount = report.results.length; + const totalDuration = localReport.totalDurationMs ? (localReport.totalDurationMs / 1000).toFixed(1) : '?'; + const successCount = localReport.results.filter((r) => r.success).length; + const totalCount = localReport.results.length; // Findings tab content const FindingsContent = () => ( -
    +
    {/* Summary Card */} - -
    + +

    {totalCount}

    Services

    @@ -442,12 +471,142 @@ export function ServiceReportView({

    {totalDuration}s

    Duration

    + {localReport.healthScore != null && ( +
    +

    = 80 ? 'text-green-500' : + localReport.healthScore >= 50 ? 'text-yellow-500' : 'text-red-500' + }`}> + {localReport.healthScore} +

    +

    + Health +

    +
    + )}
    - {/* Findings by Service - Use custom renderer if available */} - {report.results.map((result) => { + {/* Agent Summary / AI Summary Generator */} + {localReport.agentSummary ? ( + + +
    +
    + +
    +
    +
    +

    AI Analysis Summary

    + +
    +

    {localReport.agentSummary}

    +
    +
    +
    +
    + ) : ( + + +
    +
    +
    + +
    +
    +

    AI Summary

    +

    + Generate an AI analysis summary with health score +

    +
    +
    + +
    + {aiSummaryError && ( +

    {aiSummaryError}

    + )} +
    +
    + )} + + {/* Failure Summary Banner */} + {(() => { + const failedResults = localReport.results.filter(r => !r.success); + if (failedResults.length === 0) return null; + return ( + + +
    + +
    +

    + {failedResults.length} service{failedResults.length > 1 ? 's' : ''} failed +

    +
      + {failedResults.map(r => { + const def = definitionMap.get(r.serviceId); + return ( +
    • + + + {def?.name ?? r.serviceId} + {r.error && โ€” {r.error}} + +
    • + ); + })} +
    +
    +
    +
    +
    + ); + })()} + + {/* Findings by Service - sorted: failed first, then by severity of findings, then successful */} + {[...localReport.results] + .sort((a, b) => { + // Failed services first + if (!a.success && b.success) return -1; + if (a.success && !b.success) return 1; + // Then by worst finding severity + const severityRank = (findings: typeof a.findings) => { + let worst = 0; + for (const f of findings) { + const rank = f.severity === 'critical' ? 5 : f.severity === 'error' ? 4 : f.severity === 'warning' ? 3 : 1; + if (rank > worst) worst = rank; + } + return worst; + }; + return severityRank(b.findings) - severityRank(a.findings); + }) + .map((result) => { const def = definitionMap.get(result.serviceId); const CustomRenderer = getServiceRenderer(result.serviceId); @@ -464,29 +623,49 @@ export function ServiceReportView({ } // Fallback to generic renderer - const Icon = def ? getIcon(def.icon) : Wrench; + // Build a minimal definition for the wrapper if none exists + const fallbackDef: ServiceDefinition = def ?? { + id: result.serviceId, + name: result.serviceId, + description: '', + category: 'diagnostics', + estimatedDurationSecs: 0, + requiredPrograms: [], + options: [], + icon: 'wrench', + exclusiveResources: [], + dependencies: [], + }; return ( - - - -
    - + +
    + {/* Error message for failed services */} + {!result.success && result.error && ( +
    +
    + +
    +

    {result.error}

    + {result.logs.length > 0 && ( +
    + + Show last {Math.min(5, result.logs.length)} log lines + +
    + {result.logs.slice(-5).map((log, i) => ( +
    + + {log} +
    + ))} +
    +
    + )} +
    +
    - {def?.name || result.serviceId} - - {result.success ? 'PASS' : 'FAIL'} - - - {(result.durationMs / 1000).toFixed(1)}s - - - - + )} {result.findings.map((finding, idx) => { const SeverityIcon = severityIcons[finding.severity]; const colorClass = severityColors[finding.severity]; @@ -508,11 +687,22 @@ export function ServiceReportView({
    ); })} - {result.findings.length === 0 && ( + {result.findings.length === 0 && result.success && (

    No findings

    )} - - + {result.agentAnalysis && ( +
    +
    + +
    +

    AI Analysis

    +

    {result.agentAnalysis}

    +
    +
    +
    + )} +
    + ); })}
    @@ -527,12 +717,22 @@ export function ServiceReportView({
    - {report.status === 'completed' ? ( + {localReport.status === 'completed' ? ( ) : ( )} {headerTitle} + {localReport.agentInitiated && ( + + Agent + + )} + {localReport.parallelMode && ( + + Parallel + + )} {successCount}/{totalCount} in {totalDuration}s @@ -566,7 +766,7 @@ export function ServiceReportView({
    - + @@ -588,7 +788,7 @@ export function ServiceReportView({ data-print-content className="bg-white shadow-[0_4px_60px_rgba(0,0,0,0.3)] w-[550px] flex-shrink-0" > - +
    @@ -612,7 +812,7 @@ export function ServiceReportView({ className="bg-white shadow-[0_4px_60px_rgba(0,0,0,0.3)] w-[550px] flex-shrink-0" > {/* Bottom Action Bar */} -
    +
    +

    Service Queue

    +
    +
    + {enabledCount} services + + + {formatTotalTime(totalDuration)} + +
    +
    + + {/* Toolbar */} +
    +
    + + setSearchQuery(e.target.value)} + /> +
    + + + + + + onQueueChange(queue.map(q => ({ ...q, enabled: true })))}> + Enable All + + onQueueChange(queue.map(q => ({ ...q, enabled: false })))}> + Disable All + + onQueueChange([])} + > + Clear Queue + + + + +
    + + {/* Queue List */} + +
    + + q.id)} + strategy={verticalListSortingStrategy} + > +
    + {/* Enabled Services */} + {enabledQueue.length > 0 && ( +
    +

    + Enabled Services ({enabledQueue.length}) +

    +
    + + {enabledQueue.map((item) => { + const def = definitionMap.get(item.serviceId); + if (!def) return null; + return animationsEnabled ? ( + + + + ) : ( + + ); + })} + +
    +
    + )} + + {/* Disabled Services (Collapsible) */} + {disabledQueue.length > 0 && ( + + + + + +
    + + {disabledQueue.map((item) => { + const def = definitionMap.get(item.serviceId); + if (!def) return null; + return animationsEnabled ? ( + + + + ) : ( + + ); + })} + +
    +
    +
    + )} + + {filteredQueue.length === 0 && ( +
    +

    No services match your filter.

    + +
    + )} +
    +
    +
    +
    +
    + + {/* Undo Bar */} + {recentlyRemoved && (() => { + const def = definitionMap.get(recentlyRemoved.item.serviceId); + return ( +
    + + Removed {def?.name ?? 'service'} + + +
    + ); + })()} + + {/* Run Error Alert */} + {runError && ( + + + {runError} + + )} + + {/* Footer */} +
    +
    + + + + + {/* Parallel Mode Toggle */} + + + +
    + + + + {parallelMode && ( + + Experimental + + )} +
    +
    + +

    Parallel Execution (Experimental)

    +

    + Run non-conflicting services simultaneously. Services that share resources + (e.g., network tests, stress tests) still run sequentially to ensure accurate results. +

    +
    +
    +
    + + + + + + + + + {enabledCount === 0 && ( + +

    Enable at least one service in the queue to start

    +
    + )} +
    +
    +
    +
    + + {/* Add Service Dialog */} + + + + Add Service to Queue + + Choose a service to add to your current execution queue. + + + +
    + + setAddSearchQuery(e.target.value)} + autoFocus + /> +
    + + +
    + {Object.entries(groupedServices).map(([category, services]) => ( +
    +

    + {category} +

    +
    + {services.map((def) => { + const Icon = getIcon(def.icon); + return ( +
    handleAddNewService(def.id)} + > +
    + +
    +
    +

    {def.name}

    +

    {def.description}

    +
    + + + ~{def.estimatedDurationSecs}s + + +
    + ); + })} +
    +
    + ))} + {availableServices.length === 0 && ( +
    + +

    No services found

    +

    + {addSearchQuery + ? `No services match "${addSearchQuery}". Try a different search term.` + : 'No services are available.'} +

    + {addSearchQuery && ( + + )} +
    + )} +
    +
    +
    +
    + + {/* Save Preset Dialog */} + + + + Save as Preset + + Save the current enabled services ({enabledCount}) as a reusable preset. + + +
    +
    + + setSavePresetName(e.target.value)} + autoFocus + /> +
    +
    + + setSavePresetDescription(e.target.value)} + /> +
    +
    + + + + +
    +
    +
    + ); +} diff --git a/src/components/service/ResultsView.tsx b/src/components/service/ResultsView.tsx new file mode 100644 index 0000000..258a472 --- /dev/null +++ b/src/components/service/ResultsView.tsx @@ -0,0 +1,36 @@ +/** + * Results View Component + * + * Wrapper around ServiceReportView for the service page results phase. + */ + +import type { ServiceReport, ServiceDefinition } from '@/types/service'; +import { ServiceReportView } from '@/components/service-report-view'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface ResultsViewProps { + report: ServiceReport; + definitions: ServiceDefinition[]; + onNewService: () => void; + onBack: () => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ResultsView({ report, definitions, onNewService, onBack }: ResultsViewProps) { + return ( + + ); +} diff --git a/src/components/service/RunnerView.tsx b/src/components/service/RunnerView.tsx new file mode 100644 index 0000000..061b7d0 --- /dev/null +++ b/src/components/service/RunnerView.tsx @@ -0,0 +1,600 @@ +/** + * Runner View Component + * + * Real-time service execution monitor with progress tracking, + * inline error display, live findings feed, and smarter ETA. + */ + +import { useState, useEffect, useRef } from 'react'; +import { + Loader2, + Square, + CheckCircle2, + AlertCircle, + Timer, + Clock, + Zap, + FlaskConical, + ChevronDown, + ChevronRight, + XCircle, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; + +import type { + ServiceReport, + ServiceDefinition, + ServiceQueueItem, + ServiceFinding, +} from '@/types/service'; +import { getIcon, formatDuration } from './utils'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface RunnerViewProps { + report: ServiceReport | null; + definitions: ServiceDefinition[]; + logs: string[]; + onCancel: () => void; + onBack: () => void; + queue: ServiceQueueItem[]; + cancelError?: string | null; +} + +// ============================================================================= +// Severity Colors +// ============================================================================= + +const SEVERITY_COLORS: Record = { + critical: 'border-purple-500/30 bg-purple-500/5 text-purple-700 dark:text-purple-400', + error: 'border-destructive/30 bg-destructive/5 text-destructive', + warning: 'border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400', + info: 'border-blue-500/30 bg-blue-500/5 text-blue-700 dark:text-blue-400', + success: 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400', +}; + +// ============================================================================= +// Component +// ============================================================================= + +export function RunnerView({ report, definitions, logs, onCancel, onBack, queue, cancelError }: RunnerViewProps) { + const logsEndRef = useRef(null); + const [showLogs, setShowLogs] = useState(true); + const [elapsedMs, setElapsedMs] = useState(0); + const [taskElapsedMs, setTaskElapsedMs] = useState(0); + const startTimeRef = useRef(Date.now()); + const taskStartRef = useRef(Date.now()); + // Per-task elapsed timers for parallel mode (keyed by queue index) + const [parallelTaskTimers, setParallelTaskTimers] = useState>(new Map()); + const parallelTaskStartRef = useRef>(new Map()); + // Track expanded failed items + const [expandedFailed, setExpandedFailed] = useState>(new Set()); + + // Estimated times per service from the definitions + const definitionMap = new Map(definitions.map((d) => [d.id, d])); + + // Use the queue prop as fallback when report is not yet populated + const enabledServices = (report?.queue ?? queue).filter((q) => q.enabled); + const isParallel = report?.parallelMode ?? false; + const currentIndex = report?.currentServiceIndex ?? 0; + const currentIndices = report?.currentServiceIndices ?? []; + const completedCount = report?.results?.length ?? 0; + const totalCount = enabledServices.length; + const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0; + + const currentService = enabledServices[currentIndex]; + const currentDef = currentService ? definitionMap.get(currentService.serviceId) : null; + + // Smarter ETA: use actual completion times to compute speed factor + const completedResults = report?.results ?? []; + const actualCompletedMs = completedResults.reduce((s, r) => s + r.durationMs, 0); + const estimatedCompletedMs = completedResults.reduce((s, r) => { + const def = definitionMap.get(r.serviceId); + return s + (def?.estimatedDurationSecs ?? 30) * 1000; + }, 0); + const speedFactor = estimatedCompletedMs > 0 ? actualCompletedMs / estimatedCompletedMs : 1; + + // Remaining services estimate + const completedServiceIds = new Set(completedResults.map(r => r.serviceId)); + const remainingServices = enabledServices.filter(item => !completedServiceIds.has(item.serviceId)); + const remainingEstMs = remainingServices.reduce((s, item) => { + const def = definitionMap.get(item.serviceId); + return s + (def?.estimatedDurationSecs ?? 30) * 1000; + }, 0); + const activeCount = isParallel ? Math.max(1, currentIndices.length) : 1; + const estimatedRemainingMs = Math.max(0, (remainingEstMs * speedFactor) / activeCount); + + // Total estimated for overall progress bar (including speed factor) + const totalEstimatedMs = enabledServices.reduce((acc, q) => { + const def = definitionMap.get(q.serviceId); + return acc + (def?.estimatedDurationSecs ?? 30) * 1000; + }, 0); + + // Collect notable findings from completed services + const liveFindings: Array<{ serviceName: string; finding: ServiceFinding }> = []; + for (const result of completedResults) { + const def = definitionMap.get(result.serviceId); + for (const finding of result.findings) { + if (finding.severity === 'warning' || finding.severity === 'error' || finding.severity === 'critical') { + liveFindings.push({ serviceName: def?.name ?? result.serviceId, finding }); + } + } + } + + // Count failed services + const failedResults = completedResults.filter(r => !r.success); + + // Auto-scroll logs + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + + // Elapsed time ticker + useEffect(() => { + startTimeRef.current = Date.now(); + const interval = setInterval(() => { + setElapsedMs(Date.now() - startTimeRef.current); + if (!isParallel) { + setTaskElapsedMs(Date.now() - taskStartRef.current); + } else { + // Update all parallel task timers + const now = Date.now(); + const newTimers = new Map(); + parallelTaskStartRef.current.forEach((start, idx) => { + newTimers.set(idx, now - start); + }); + setParallelTaskTimers(newTimers); + } + }, 1000); + return () => clearInterval(interval); + }, [isParallel]); + + // Track when current task changes (sequential mode) + useEffect(() => { + if (!isParallel) { + taskStartRef.current = Date.now(); + } + }, [currentIndex, isParallel]); + + // Track parallel task starts/stops + useEffect(() => { + if (isParallel) { + const prevIndices = new Set(parallelTaskStartRef.current.keys()); + const newIndices = new Set(currentIndices); + + // Add new tasks + for (const idx of currentIndices) { + if (!prevIndices.has(idx)) { + parallelTaskStartRef.current.set(idx, Date.now()); + } + } + // Remove completed tasks + for (const idx of prevIndices) { + if (!newIndices.has(idx)) { + parallelTaskStartRef.current.delete(idx); + } + } + } + }, [currentIndices, isParallel]); + + // Build service status list + const activeIndexSet = new Set(isParallel ? currentIndices : (currentService ? [currentIndex] : [])); + + const serviceStatuses = enabledServices.map((item, index) => { + const def = definitionMap.get(item.serviceId); + const result = report?.results?.find(r => r.serviceId === item.serviceId); + let status: 'pending' | 'running' | 'completed' | 'failed' = 'pending'; + if (completedServiceIds.has(item.serviceId)) { + status = result?.success ? 'completed' : 'failed'; + } else if (isParallel ? activeIndexSet.has(index) : index === currentIndex) { + status = 'running'; + } + return { item, def, result, status, index }; + }); + + // Get active services for the header display + const activeServices = serviceStatuses.filter(s => s.status === 'running'); + + const toggleFailedExpanded = (serviceId: string) => { + setExpandedFailed(prev => { + const next = new Set(prev); + if (next.has(serviceId)) next.delete(serviceId); + else next.add(serviceId); + return next; + }); + }; + + return ( +
    + {/* Header */} +
    +
    +
    + +
    +
    +
    +

    Running Services

    + {isParallel && ( + + + Parallel + + )} +
    +

    + {isParallel ? ( + activeServices.length > 0 ? ( + <> + {activeServices.length} running + {' โ€” '} + {completedCount} of {totalCount} complete + + ) : ( + 'Starting...' + ) + ) : ( + currentDef ? ( + <> + {currentDef.name} + {' โ€” '} + Step {currentIndex + 1} of {totalCount} + + ) : ( + 'Starting...' + ) + )} +

    +
    + + {/* Control Buttons */} +
    + + {cancelError && ( +

    {cancelError}

    + )} +
    +
    + + {/* Progress Section */} +
    + {/* Progress Bar */} +
    +
    + + {completedCount} of {totalCount} tasks complete + {isParallel && activeServices.length > 0 && ( + ({activeServices.length} active) + )} + + {Math.round(progress)}% +
    + +
    + + {/* Time Stats */} +
    +
    + +
    + Elapsed:{' '} + {formatDuration(elapsedMs)} +
    +
    + {!isParallel && ( +
    + +
    + Task:{' '} + {formatDuration(taskElapsedMs)} +
    +
    + )} +
    + +
    + ETA:{' '} + + {estimatedRemainingMs > 0 ? `~${formatDuration(estimatedRemainingMs)}` : 'Almost done'} + +
    +
    +
    +
    +
    + + {/* Main Content: Task List */} + +
    + {/* Failed Services Banner */} + {failedResults.length > 0 && ( + + + + {failedResults.length} service{failedResults.length > 1 ? 's' : ''} failed + {' โ€” '} + {failedResults.map((r, i) => { + const def = definitionMap.get(r.serviceId); + return ( + + {i > 0 && ', '} + {def?.name ?? r.serviceId}{r.error ? `: ${r.error}` : ''} + + ); + })} + + + )} + + {/* Active Task Cards */} + {isParallel ? ( + // Parallel mode: show all active tasks + activeServices.length > 0 && ( +
    + {activeServices.map(({ item, def, index }) => { + if (!def) return null; + const Icon = getIcon(def.icon); + const elapsed = parallelTaskTimers.get(index) ?? 0; + return ( + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    {def.name}

    + + Running + +
    +

    {def.description}

    +
    +
    +
    {formatDuration(elapsed)}
    +
    ~{def.estimatedDurationSecs}s est.
    +
    +
    +
    + + ); + })} +
    + ) + ) : ( + // Sequential mode: show single active task card with per-service progress + currentDef && ( + +
    +
    +
    +
    + {(() => { const Icon = getIcon(currentDef.icon); return ; })()} +
    +
    +
    +
    +
    +

    {currentDef.name}

    + + Running + +
    +

    {currentDef.description}

    +
    +
    +
    {formatDuration(taskElapsedMs)}
    +
    ~{currentDef.estimatedDurationSecs}s est.
    +
    +
    + + {/* Per-service estimated progress bar */} + + + {/* Latest log line */} + {logs.length > 0 && ( +
    + + {logs[logs.length - 1]} +
    + )} +
    + + ) + )} + + {/* Live Findings Feed */} + {liveFindings.length > 0 && ( +
    +

    + Findings So Far ({liveFindings.length}) +

    + {liveFindings.slice(-5).map((f, i) => ( +
    + {f.serviceName}:{' '} + {f.finding.title} +
    + ))} + {liveFindings.length > 5 && ( +

    + +{liveFindings.length - 5} more findings... +

    + )} +
    + )} + + {/* Task Queue List */} +
    +
    +

    + Task Queue +

    + +
    + + {serviceStatuses.map(({ item, def, result, status, index }) => { + if (!def) return null; + const Icon = getIcon(def.icon); + const isActive = status === 'running'; + const isFailed = status === 'failed'; + const taskElapsed = isParallel ? (parallelTaskTimers.get(index) ?? 0) : taskElapsedMs; + const isExpanded = expandedFailed.has(item.serviceId); + + return ( +
    +
    toggleFailedExpanded(item.serviceId) : undefined} + > + {/* Status Indicator */} +
    + {status === 'running' ? ( + + ) : status === 'completed' ? ( + + ) : isFailed ? ( + + ) : ( +
    + )} +
    + + {/* Service Icon */} +
    + +
    + + {/* Name + Error */} +
    + + {def.name} + + {isFailed && result?.error && ( +

    + {result.error} +

    + )} +
    + + {/* Duration / Status */} +
    + {status === 'completed' && result ? ( + + {formatDuration(result.durationMs)} + + ) : isFailed && result ? ( + Failed + ) : status === 'running' ? ( + {formatDuration(taskElapsed)} + ) : ( + ~{def.estimatedDurationSecs}s + )} + {isFailed && ( + + )} +
    +
    + + {/* Expanded error details for failed services */} + {isFailed && isExpanded && result && ( +
    + {result.error && ( +

    {result.error}

    + )} + {result.logs.length > 0 && ( +
    + {result.logs.slice(-10).map((log, idx) => ( +
    + + {log} +
    + ))} +
    + )} +
    + )} +
    + ); + })} +
    + + {/* Expandable Log Section */} + {showLogs && ( +
    +
    + {logs.length === 0 && ( +
    Starting services...
    + )} + {logs.map((log, idx) => ( +
    + + {log} +
    + ))} +
    +
    +
    + )} +
    + +
    + ); +} diff --git a/src/components/service/SortableQueueItem.tsx b/src/components/service/SortableQueueItem.tsx new file mode 100644 index 0000000..4c741b7 --- /dev/null +++ b/src/components/service/SortableQueueItem.tsx @@ -0,0 +1,443 @@ +/** + * Sortable Queue Item Component + * + * Drag-and-drop sortable service queue item with option editing, + * toggle, duplicate, and remove actions. + * + * Clicking anywhere on the row toggles the service enabled/disabled. + * Drag handle, action buttons, and option inputs are excluded from toggle. + */ + +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + GripVertical, + Loader2, + Clock, + AlertCircle, + Copy, + Trash2, + RefreshCw, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; + +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import type { ServiceQueueItem, ServiceDefinition } from '@/types/service'; +import { getIcon } from './utils'; +import { useAnimation } from '@/components/animation-context'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface SortableQueueItemProps { + item: ServiceQueueItem; + definition: ServiceDefinition; + onToggle: (itemId: string) => void; + onOptionsChange: (itemId: string, options: Record) => void; + onDuplicate: (itemId: string) => void; + onRemove: (itemId: string) => void; + conflictWith?: string[]; + /** Names of services this service depends on (must run before) */ + dependencyNames?: string[]; + /** Whether a dependency ordering violation exists */ + dependencyViolation?: boolean; +} + +// ============================================================================= +// Component +// ============================================================================= + +export function SortableQueueItem({ + item, + definition, + onToggle, + onOptionsChange, + onDuplicate, + onRemove, + conflictWith, + dependencyNames, + dependencyViolation, +}: SortableQueueItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: item.id }); + + const { animationsEnabled } = useAnimation(); + + const [estimatedMs, setEstimatedMs] = useState(null); + const [estimateLoading, setEstimateLoading] = useState(false); + const [usbDrives, setUsbDrives] = useState>([]); + const [usbLoading, setUsbLoading] = useState(false); + + // Fetch USB drives when a usb_drive option is present + const hasUsbOption = definition.options.some(o => o.optionType === 'usb_drive'); + useEffect(() => { + if (!hasUsbOption || !item.enabled) return; + const loadDrives = async () => { + setUsbLoading(true); + try { + const drives = await invoke>('list_usb_drives'); + setUsbDrives(drives); + } catch (err) { + console.error('Failed to list USB drives:', err); + setUsbDrives([]); + } finally { + setUsbLoading(false); + } + }; + loadDrives(); + }, [hasUsbOption, item.enabled]); + + const refreshUsbDrives = async () => { + setUsbLoading(true); + try { + const drives = await invoke>('list_usb_drives'); + setUsbDrives(drives); + } catch (err) { + console.error('Failed to refresh USB drives:', err); + } finally { + setUsbLoading(false); + } + }; + + // Fetch estimated time when options change + useEffect(() => { + let active = true; + setEstimateLoading(true); + const fetchEstimate = async () => { + try { + const ms = await invoke('get_estimated_time', { + serviceId: item.serviceId, + options: item.options, + defaultSecs: definition.estimatedDurationSecs, + }); + if (active) { + setEstimatedMs(ms); + setEstimateLoading(false); + } + } catch (err) { + console.error('Failed to estimate time:', err); + if (active) setEstimateLoading(false); + } + }; + + const timer = setTimeout(fetchEstimate, 500); // Debounce + return () => { + active = false; + clearTimeout(timer); + }; + }, [item.serviceId, item.options, definition.estimatedDurationSecs]); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : 1, + }; + + const Icon = getIcon(definition.icon); + + // Helper to format time + const formatTime = (ms: number | undefined) => { + if (ms === undefined) return `~${definition.estimatedDurationSecs}s`; + const secs = Math.round(ms / 1000); + if (secs < 60) return `${secs}s`; + return `${(secs / 60).toFixed(1)}m`; + }; + + // Click handler for the row โ€” toggles enabled/disabled + const handleRowClick = (e: React.MouseEvent) => { + // Don't toggle if clicking on interactive elements inside the row + const target = e.target as HTMLElement; + if (target.closest('[data-no-toggle]') || target.closest('input') || target.closest('button') || target.closest('[role="combobox"]')) { + return; + } + onToggle(item.id); + }; + + const Wrapper = animationsEnabled ? motion.div : 'div' as unknown as typeof motion.div; + const layoutProps = animationsEnabled ? { layout: true as const, transition: { layout: { duration: 0.2, ease: [0.4, 0, 0.2, 1] } } } : {}; + + return ( + + {/* Main row โ€” clickable to toggle */} +
    + {/* Drag Handle */} + {item.enabled ? ( + + ) : ( +
    + )} + + {/* Service Icon */} +
    + +
    + + {/* Service Info */} +
    +
    +

    {definition.name}

    +
    + {estimateLoading ? ( + + ) : ( + + )} + {estimateLoading ? '...' : formatTime(estimatedMs ?? undefined)} +
    +
    +

    {definition.description}

    + {conflictWith && conflictWith.length > 0 && ( +
    + + Resource conflict with: {conflictWith.join(', ')} +
    + )} + {dependencyNames && dependencyNames.length > 0 && ( +
    + + + {dependencyViolation ? 'Must run after: ' : 'Requires: '} + {dependencyNames.join(', ')} + +
    + )} +
    + + {/* Actions โ€” excluded from toggle via data-no-toggle / button */} +
    + + + +
    +
    + + {/* Options โ€” animated expand/collapse */} + + {item.enabled && definition.options.length > 0 && ( + +
    + {definition.options.map((opt) => ( +
    + {opt.optionType === 'usb_drive' ? ( +
    + + {usbDrives.length > 0 ? ( + + ) : ( + + {usbLoading ? 'Scanning...' : 'No USB drives detected'} + + )} + +
    + ) : opt.optionType === 'boolean' ? ( +
    + + onOptionsChange(item.id, { + ...item.options, + [opt.id]: checked + }) + } + className="scale-75 origin-left" + /> + +
    + ) : opt.optionType === 'select' && opt.options ? ( +
    + + +
    + ) : ( + <> + + {opt.optionType === 'number' && ( + + onOptionsChange(item.id, { + ...item.options, + [opt.id]: parseFloat(e.target.value) || opt.defaultValue, + }) + } + className="h-7 w-20 text-xs" + /> + )} + {opt.optionType === 'string' && ( + + onOptionsChange(item.id, { + ...item.options, + [opt.id]: e.target.value, + }) + } + className="h-7 w-full min-w-[100px] text-xs" + /> + )} + + )} +
    + ))} +
    +
    + )} +
    + + ); +} diff --git a/src/components/service/utils.ts b/src/components/service/utils.ts new file mode 100644 index 0000000..de544c1 --- /dev/null +++ b/src/components/service/utils.ts @@ -0,0 +1,95 @@ +/** + * Service Component Utilities + * + * Shared icon mapping, formatters, and constants used across + * the service page sub-components. + */ + +import { + Wrench, + Stethoscope, + ShieldCheck, + Settings2, + Wifi, + HardDrive, + Gauge, + BatteryFull, + ShieldAlert, + Sparkles, + MonitorCheck, + Activity, + Download, + Network, + Trash2, + Usb, + Weight, + PackageCheck, + FileSearch, + CloudDownload, + Zap, + BatteryCharging, + PackageSearch, + Globe, +} from 'lucide-react'; + +// ============================================================================= +// Icon Mapping +// ============================================================================= + +export const ICON_MAP: Record> = { + stethoscope: Stethoscope, + wrench: Wrench, + 'shield-check': ShieldCheck, + 'settings-2': Settings2, + wifi: Wifi, + 'hard-drive': HardDrive, + gauge: Gauge, + 'battery-full': BatteryFull, + 'shield-alert': ShieldAlert, + sparkles: Sparkles, + 'monitor-check': MonitorCheck, + activity: Activity, + download: Download, + network: Network, + 'trash-2': Trash2, + usb: Usb, + weight: Weight, + 'package-check': PackageCheck, + 'file-scan': FileSearch, + 'cloud-download': CloudDownload, + zap: Zap, + 'battery-charging': BatteryCharging, + 'package-search': PackageSearch, + globe: Globe, +}; + +export function getIcon(iconName: string) { + return ICON_MAP[iconName] || Wrench; +} + +// ============================================================================= +// Formatters +// ============================================================================= + +/** Format milliseconds into a human-readable duration string */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return `${minutes}m ${seconds}s`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours}h ${mins}m`; +} + +// ============================================================================= +// Preset Gradients +// ============================================================================= + +export const PRESET_GRADIENTS: Record = { + diagnostics: { from: 'from-blue-500/20', to: 'to-cyan-500/10', accent: 'text-blue-500', bullet: 'bg-blue-500' }, + general: { from: 'from-emerald-500/20', to: 'to-green-500/10', accent: 'text-emerald-500', bullet: 'bg-emerald-500' }, + complete: { from: 'from-violet-500/20', to: 'to-purple-500/10', accent: 'text-violet-500', bullet: 'bg-violet-500' }, + custom: { from: 'from-amber-500/20', to: 'to-orange-500/10', accent: 'text-amber-500', bullet: 'bg-amber-500' }, +}; diff --git a/src/components/titlebar.tsx b/src/components/titlebar.tsx index 0ba36a0..9b34f50 100644 --- a/src/components/titlebar.tsx +++ b/src/components/titlebar.tsx @@ -17,10 +17,11 @@ export function Titlebar() { return (
    {/* App title and drag region */} -
    + RustService
    diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 37a7d4b..f986f14 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -36,20 +36,18 @@ const buttonVariants = cva( } ) -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { +const Button = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + } +>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( ) -} +}) + +Button.displayName = "Button" export { Button, buttonVariants } diff --git a/src/hooks/useConversations.ts b/src/hooks/useConversations.ts new file mode 100644 index 0000000..04575b0 --- /dev/null +++ b/src/hooks/useConversations.ts @@ -0,0 +1,204 @@ +/** + * useConversations Hook + * + * Manages conversation lifecycle: creating, loading, saving conversations + * and their associated state (title, ID, first-message tracking). + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { CoreMessage, generateId } from 'ai'; +import { type MessagePart } from '@/components/agent/ChatMessage'; +import { mapToolToActivityType, extractActivityDetails } from '@/lib/agent-activity-utils'; +import type { Conversation, ConversationMessage, ConversationWithMessages } from '@/types/agent'; +import type { AgentActivity } from '@/types/agent-activity'; + +/** UI message shape used throughout AgentPage */ +export interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + createdAt: string; + parts?: MessagePart[]; + attachments?: import('@/types/file-attachment').FileAttachment[]; +} + +interface UseConversationsParams { + isConfigured: boolean; + /** Setter to replace the UI messages array (must keep messagesRef in sync). */ + setMessages: (update: Message[] | ((prev: Message[]) => Message[])) => void; + /** Ref to the CoreMessage history for the agent loop. */ + agentHistoryRef: React.MutableRefObject; +} + +export function useConversations({ + isConfigured, + setMessages, + agentHistoryRef, +}: UseConversationsParams) { + const [currentConversationId, setCurrentConversationId] = useState(null); + const [, setConversationTitle] = useState('New Chat'); + const isFirstMessageRef = useRef(true); + + // Save conversation to backend + const saveConversation = useCallback(async (msgs: Message[], history: CoreMessage[]) => { + if (!currentConversationId || msgs.length === 0) return; + + try { + // Convert messages to ConversationMessage format + const conversationMessages: ConversationMessage[] = history.map((msg, index) => ({ + id: generateId(), + conversationId: currentConversationId, + role: msg.role, + content: JSON.stringify(msg.content), + createdAt: msgs[Math.floor(index / 2)]?.createdAt || new Date().toISOString(), + })); + + await invoke('save_conversation_messages', { + conversationId: currentConversationId, + messages: conversationMessages, + }); + } catch (err) { + console.error('Failed to save conversation:', err); + } + }, [currentConversationId]); + + // Load a conversation + const loadConversation = useCallback(async (conversation: Conversation) => { + try { + const data = await invoke('get_conversation', { + conversationId: conversation.id, + }); + + // Convert stored messages back to CoreMessage format for history + const history: CoreMessage[] = data.messages.map((msg) => ({ + role: msg.role as 'user' | 'assistant' | 'tool', + content: JSON.parse(msg.content), + })); + + // Build a lookup of tool results by toolCallId for activity reconstruction + const toolResults = new Map(); + for (const msg of data.messages) { + if (msg.role === 'tool') { + const content = JSON.parse(msg.content); + const parts = Array.isArray(content) ? content : [content]; + for (const part of parts) { + if (part.type === 'tool-result' && part.toolCallId) { + const resultData = part.result; + let output: string; + let isError = !!part.isError; + if (typeof resultData === 'string') { + output = resultData; + } else if (resultData && typeof resultData === 'object') { + isError = isError || resultData.status === 'error'; + output = resultData.output || resultData.error || JSON.stringify(resultData); + } else { + output = JSON.stringify(resultData); + } + toolResults.set(part.toolCallId, { output, isError }); + } + } + } + } + + // Convert to UI Message format - reconstruct interleaved parts from tool-call parts + const uiMessages: Message[] = []; + for (const msg of data.messages) { + if (msg.role === 'user') { + const content = JSON.parse(msg.content); + uiMessages.push({ + id: msg.id, + role: 'user', + content: typeof content === 'string' ? content : '', + createdAt: msg.createdAt, + }); + } else if (msg.role === 'assistant') { + const content = JSON.parse(msg.content); + let textContent = ''; + const parts: MessagePart[] = []; + + if (typeof content === 'string') { + textContent = content; + if (content) parts.push({ type: 'text', content }); + } else if (Array.isArray(content)) { + // Build interleaved parts preserving order + for (const part of content) { + if (part.type === 'text' && part.text) { + textContent += part.text; + parts.push({ type: 'text', content: part.text }); + } else if (part.type === 'tool-call') { + const toolName = part.toolName || ''; + const args = part.args || part.input || {}; + const activityType = mapToolToActivityType(toolName); + const activityDetails = extractActivityDetails(toolName, args); + const result = toolResults.get(part.toolCallId); + + parts.push({ + type: 'tool', + activity: { + id: part.toolCallId, + timestamp: msg.createdAt, + type: activityType, + status: result ? (result.isError ? 'error' : 'success') : 'success', + output: result?.output, + error: result?.isError ? result.output : undefined, + ...activityDetails, + } as AgentActivity, + }); + } + } + } + + uiMessages.push({ + id: msg.id, + role: 'assistant', + content: textContent, + createdAt: msg.createdAt, + parts, + }); + } + // 'tool' messages are consumed via the toolResults lookup, not shown directly + } + + setCurrentConversationId(conversation.id); + setConversationTitle(conversation.title); + setMessages(uiMessages); + agentHistoryRef.current = history; + isFirstMessageRef.current = uiMessages.length === 0; + } catch (err) { + console.error('Failed to load conversation:', err); + } + }, [setMessages, agentHistoryRef]); + + // Start a new conversation + const startNewConversation = useCallback(async () => { + try { + const conversation = await invoke('create_conversation', { title: null }); + setCurrentConversationId(conversation.id); + setConversationTitle('New Chat'); + setMessages([]); + agentHistoryRef.current = []; + isFirstMessageRef.current = true; + } catch (err) { + console.error('Failed to create conversation:', err); + } + }, [setMessages, agentHistoryRef]); + + // Create initial conversation on mount if none exists + useEffect(() => { + if (!currentConversationId && isConfigured) { + startNewConversation(); + } + }, [currentConversationId, isConfigured, startNewConversation]); + + return { + currentConversationId, + setCurrentConversationId, + conversationTitle: undefined as string | undefined, // kept for API compat + setConversationTitle, + saveConversation, + loadConversation, + startNewConversation, + isFirstMessageRef, + }; +} diff --git a/src/hooks/useServiceSupervision.ts b/src/hooks/useServiceSupervision.ts new file mode 100644 index 0000000..3048a6c --- /dev/null +++ b/src/hooks/useServiceSupervision.ts @@ -0,0 +1,139 @@ +/** + * useServiceSupervision Hook + * + * Manages service run supervision: listens for Tauri service events + * (state changes, completion) and injects updates into the agent loop queue. + */ + +import { useState, useEffect, useRef } from 'react'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { CoreMessage } from 'ai'; +import type { AgentLoopQueue } from '@/lib/agent-loop-queue'; +import type { ServiceReport, ServiceRunState as ServiceRunStateType, ServiceResult } from '@/types/service'; + +export interface ActiveServiceRun { + reportId: string; + startedAt: string; + lastResultCount: number; + assistantMsgId: string | null; +} + +/** Format a service result into a concise text summary for the agent */ +function formatServiceResultForAgent(result: ServiceResult): string { + const lines: string[] = []; + lines.push(`Status: ${result.success ? 'SUCCESS' : 'FAILED'}`); + if (result.error) lines.push(`Error: ${result.error}`); + lines.push(`Duration: ${(result.durationMs / 1000).toFixed(1)}s`); + lines.push(`Findings (${result.findings.length}):`); + for (const f of result.findings.slice(0, 10)) { + lines.push(` [${f.severity.toUpperCase()}] ${f.title}: ${f.description}`); + if (f.recommendation) lines.push(` โ†’ ${f.recommendation}`); + } + if (result.findings.length > 10) { + lines.push(` ... and ${result.findings.length - 10} more findings`); + } + return lines.join('\n'); +} + +interface UseServiceSupervisionParams { + /** Ref to the CoreMessage history for the agent loop. */ + agentHistoryRef: React.MutableRefObject; + /** Ref to the agent loop queue for enqueuing follow-up requests. */ + loopQueueRef: React.MutableRefObject; +} + +export function useServiceSupervision({ + agentHistoryRef, + loopQueueRef, +}: UseServiceSupervisionParams) { + const [activeServiceRun, setActiveServiceRun] = useState(null); + const activeServiceRunRef = useRef(activeServiceRun); + activeServiceRunRef.current = activeServiceRun; + + // Listen for service events and inject updates via queue + useEffect(() => { + if (!activeServiceRun) return; + + const unlisteners: Promise[] = []; + + // Listen for state changes (service completed, progress updates) + unlisteners.push( + listen('service-state-changed', (event) => { + try { + const run = activeServiceRunRef.current; + if (!run) return; + + const state = event.payload; + const report = state.currentReport; + if (!report?.results) return; + + // Check if new results have appeared since last check + const newResultCount = report.results.length; + if (newResultCount > run.lastResultCount) { + const newResults = report.results.slice(run.lastResultCount); + setActiveServiceRun(prev => prev ? { ...prev, lastResultCount: newResultCount } : null); + + // Combine ALL new results into a single update message + const combinedContent = newResults + .map(r => `${r.serviceId} completed:\n${formatServiceResultForAgent(r)}`) + .join('\n\n---\n\n'); + + const updateMsg: CoreMessage = { + role: 'user', + content: `[SERVICE UPDATE โ€” ${newResults.length} service(s) completed]\n\n${combinedContent}`, + }; + const history = [...agentHistoryRef.current, updateMsg]; + agentHistoryRef.current = history; + + // Route through queue instead of calling runAgentLoop directly + loopQueueRef.current.enqueue({ + history, + options: { + reuseMessageId: run.assistantMsgId, + }, + serviceUpdate: { content: combinedContent }, + }); + } + } catch (err) { + console.error('[AgentPage] Error in service-state-changed handler:', err); + } + }) + ); + + // Listen for run completion + unlisteners.push( + listen('service-completed', (event) => { + try { + const run = activeServiceRunRef.current; + if (!run) return; + + const report = event.payload; + const resultCount = report?.results?.length ?? 0; + const summaryContent = `Report ID: ${report.id}\nStatus: ${report.status}\nServices run: ${resultCount}\nDuration: ${report.totalDurationMs ? (report.totalDurationMs / 1000).toFixed(1) + 's' : 'unknown'}\n\nAll services have finished. Please review the results using get_service_report and get_report_statistics, then write analysis and generate the PDF report.`; + const summaryMsg: CoreMessage = { + role: 'user', + content: `[SERVICE RUN COMPLETE] ${summaryContent}`, + }; + const history = [...agentHistoryRef.current, summaryMsg]; + agentHistoryRef.current = history; + setActiveServiceRun(null); + + // Route through queue for post-run review + loopQueueRef.current.enqueue({ + history, + }); + } catch (err) { + console.error('[AgentPage] Error in service-completed handler:', err); + setActiveServiceRun(null); + } + }) + ); + + return () => { + unlisteners.forEach(p => p.then(fn => fn())); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeServiceRun?.reportId]); + + return { activeServiceRun, setActiveServiceRun }; +} diff --git a/src/lib/agent-activity-utils.ts b/src/lib/agent-activity-utils.ts new file mode 100644 index 0000000..13c56da --- /dev/null +++ b/src/lib/agent-activity-utils.ts @@ -0,0 +1,255 @@ +/** + * Agent Activity Utilities + * + * Pure functions for mapping tool calls to activity types, extracting + * display details, and validating tool arguments. These are used by + * AgentPage.tsx and conversation loading. + */ + +import type { ActivityType } from '@/types/agent-activity'; + +// ============================================================================= +// Tool Call Validation +// ============================================================================= + +export function validateToolCall( + toolName: string, + args: Record, +): { valid: boolean; error?: string } { + switch (toolName) { + case 'execute_command': + if (!args.command || typeof args.command !== 'string' || !args.command.trim()) { + return { valid: false, error: 'Missing or empty command argument' }; + } + return { valid: true }; + case 'write_file': + if (!args.path || typeof args.path !== 'string' || !args.path.trim()) { + return { valid: false, error: 'Missing or invalid path argument' }; + } + // Validate Windows absolute path (e.g., C:\ or \\server\share) + if (!/^[a-zA-Z]:\\|^\\\\/.test(args.path.trim())) { + return { valid: false, error: 'Path must be an absolute Windows path (e.g., C:\\path\\to\\file.txt)' }; + } + if (args.content === undefined || args.content === null) { + return { valid: false, error: 'Missing content argument' }; + } + return { valid: true }; + case 'edit_file': + if (!args.path || typeof args.path !== 'string' || !args.path.trim()) { + return { valid: false, error: 'Missing or invalid path argument' }; + } + if (!/^[a-zA-Z]:\\|^\\\\/.test(args.path.trim())) { + return { valid: false, error: 'Path must be an absolute Windows path (e.g., C:\\path\\to\\file.txt)' }; + } + if (!args.oldString || typeof args.oldString !== 'string') { + return { valid: false, error: 'Missing or invalid oldString argument' }; + } + if (!args.newString || typeof args.newString !== 'string') { + return { valid: false, error: 'Missing or invalid newString argument' }; + } + return { valid: true }; + case 'generate_file': + if (!args.filename || typeof args.filename !== 'string') { + return { valid: false, error: 'Missing or invalid filename argument' }; + } + if (args.content === undefined || args.content === null) { + return { valid: false, error: 'Missing content argument' }; + } + if (!args.description || typeof args.description !== 'string') { + return { valid: false, error: 'Missing or invalid description argument' }; + } + return { valid: true }; + case 'read_file': + if (!args.path || typeof args.path !== 'string') { + return { valid: false, error: 'Missing or invalid path argument' }; + } + return { valid: true }; + case 'move_file': + case 'copy_file': + if (!args.src || typeof args.src !== 'string') { + return { valid: false, error: 'Missing source path' }; + } + if (!args.dest || typeof args.dest !== 'string') { + return { valid: false, error: 'Missing destination path' }; + } + return { valid: true }; + default: + return { valid: true }; + } +} + +// ============================================================================= +// Tool โ†’ Activity Type Mapping +// ============================================================================= + +export function mapToolToActivityType(toolName: string): ActivityType { + if (toolName.startsWith('mcp_')) return 'mcp_tool'; + switch (toolName) { + case 'execute_command': return 'ran_command'; + case 'write_file': return 'write_file'; + case 'edit_file': return 'edit_file'; + case 'read_file': return 'read_file'; + case 'move_file': return 'move_file'; + case 'copy_file': return 'copy_file'; + case 'list_dir': return 'list_dir'; + case 'list_programs': return 'list_dir'; + case 'list_instruments': return 'list_dir'; + case 'run_instrument': return 'ran_command'; + case 'generate_file': return 'generate_file'; + case 'grep': return 'searched'; + case 'glob': return 'searched'; + case 'search_web': return 'web_search'; + case 'get_system_info': return 'get_system_info'; + // Service tools + case 'run_service_queue': return 'service_queue_started'; + case 'pause_service': return 'service_paused'; + case 'resume_service': return 'service_resumed'; + case 'cancel_service': return 'service_cancelled'; + case 'list_services': return 'service_query'; + case 'list_service_presets': return 'service_query'; + case 'check_service_requirements': return 'service_query'; + case 'get_service_status': return 'service_query'; + case 'get_service_report': return 'service_report'; + case 'get_report_statistics': return 'service_report'; + case 'edit_finding': return 'service_edit'; + case 'add_finding': return 'service_edit'; + case 'remove_finding': return 'service_edit'; + case 'set_report_summary': return 'service_edit'; + case 'set_service_analysis': return 'service_edit'; + case 'set_health_score': return 'service_edit'; + case 'generate_report_pdf': return 'service_pdf'; + default: return 'ran_command'; + } +} + +// ============================================================================= +// Extract Display Details from Tool Args +// ============================================================================= + +export function extractActivityDetails( + toolName: string, + args: Record, +): Record { + const getPath = (p: unknown) => (typeof p === 'string' ? p : ''); + const getFilename = (p: unknown) => + typeof p === 'string' ? p.split(/[/\\]/).pop() || '' : ''; + const truncate = (value?: string) => { + if (!value) return ''; + const compact = value.replace(/\s+/g, ' ').trim(); + return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact; + }; + const stringifyArgs = () => { + try { + return JSON.stringify(args); + } catch { + return ''; + } + }; + + switch (toolName) { + case 'execute_command': + return { command: typeof args.command === 'string' ? args.command : '' }; + case 'write_file': + return { + path: getPath(args.path), + filename: getFilename(args.path), + content: typeof args.content === 'string' ? args.content : undefined, + }; + case 'edit_file': + return { + path: getPath(args.path), + filename: getFilename(args.path), + oldString: typeof args.oldString === 'string' ? truncate(args.oldString) : undefined, + newString: typeof args.newString === 'string' ? truncate(args.newString) : undefined, + all: typeof args.all === 'boolean' ? args.all : undefined, + }; + case 'read_file': + return { path: getPath(args.path), filename: getFilename(args.path) }; + case 'move_file': + return { src: getPath(args.src), dest: getPath(args.dest) }; + case 'copy_file': + return { src: getPath(args.src), dest: getPath(args.dest) }; + case 'generate_file': + return { + filename: typeof args.filename === 'string' ? args.filename : 'generated-file', + description: typeof args.description === 'string' ? args.description : '', + }; + case 'list_dir': + return { path: getPath(args.path) }; + case 'list_programs': + return { path: 'data/programs' }; + case 'list_instruments': + return { path: 'data/instruments' }; + case 'grep': + return { query: typeof args.pattern === 'string' ? args.pattern : '' }; + case 'glob': + return { query: typeof args.pattern === 'string' ? args.pattern : '' }; + case 'search_web': + return { query: typeof args.query === 'string' ? args.query : '' }; + case 'get_system_info': + return {}; + case 'run_instrument': + return { command: `Running instrument: ${typeof args.name === 'string' ? args.name : 'unknown'}` }; + // Service tools + case 'run_service_queue': { + const queue = Array.isArray(args.queue) ? args.queue : []; + return { + serviceCount: queue.filter((q: any) => q.enabled !== false).length, + reason: typeof args.reason === 'string' ? args.reason : undefined, + }; + } + case 'pause_service': + return { reason: typeof args.reason === 'string' ? args.reason : undefined }; + case 'resume_service': + return { reason: typeof args.reason === 'string' ? args.reason : undefined }; + case 'cancel_service': + return { reason: typeof args.reason === 'string' ? args.reason : undefined }; + case 'list_services': + return { queryType: 'List services' }; + case 'list_service_presets': + return { queryType: 'List presets' }; + case 'check_service_requirements': + return { + queryType: 'Check requirements', + detail: typeof args.service_id === 'string' ? args.service_id : undefined, + }; + case 'get_service_status': + return { queryType: 'Service status' }; + case 'get_service_report': + return { + reportAction: 'Get report', + reportId: typeof args.report_id === 'string' ? args.report_id : undefined, + }; + case 'get_report_statistics': + return { + reportAction: 'Get statistics', + reportId: typeof args.report_id === 'string' ? args.report_id : undefined, + }; + case 'edit_finding': + return { editAction: 'Edit finding', detail: typeof args.title === 'string' ? args.title : undefined }; + case 'add_finding': + return { editAction: 'Add finding', detail: typeof args.title === 'string' ? args.title : undefined }; + case 'remove_finding': + return { editAction: 'Remove finding', detail: typeof args.title === 'string' ? args.title : undefined }; + case 'set_report_summary': + return { editAction: 'Set summary' }; + case 'set_service_analysis': + return { + editAction: 'Set analysis', + detail: typeof args.service_id === 'string' ? args.service_id : undefined, + }; + case 'set_health_score': + return { + editAction: 'Set health score', + detail: typeof args.score === 'number' ? `Score: ${args.score}` : undefined, + }; + case 'generate_report_pdf': + return { reportId: typeof args.report_id === 'string' ? args.report_id : undefined }; + default: + if (toolName.startsWith('mcp_')) { + return { toolName, arguments: stringifyArgs() }; + } + console.warn('[Agent] Unknown tool for activity details:', toolName); + return {}; + } +} diff --git a/src/lib/agent-chat.ts b/src/lib/agent-chat.ts new file mode 100644 index 0000000..80b7c9a --- /dev/null +++ b/src/lib/agent-chat.ts @@ -0,0 +1,428 @@ +/** + * Agent Chat Service + * + * Handles AI streaming with multi-provider support using Vercel AI SDK. + * Supports: OpenAI, Anthropic, xAI, Google, Mistral, DeepSeek, Groq, OpenRouter, Ollama, Custom + */ + +import { + streamText, + stepCountIs, + type CoreMessage, + type LanguageModel, + type ToolSet, + type TextStreamPart, +} from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createMistral } from "@ai-sdk/mistral"; +import { createXai } from "@ai-sdk/xai"; +import { createGroq } from "@ai-sdk/groq"; +import type { + AgentSettings, + AgentProvider, + ProviderApiKeys, +} from "@/types/agent"; +import { invoke } from "@tauri-apps/api/core"; + +// ============================================================================= +// Provider Configuration +// ============================================================================= + +/** + * Base URLs for OpenAI-compatible providers + */ +const PROVIDER_BASE_URLS: Partial> = { + deepseek: "https://api.deepseek.com", + openrouter: "https://openrouter.ai/api/v1", +}; + +/** + * Create a language model instance based on provider settings + */ +export function createProviderModel(settings: AgentSettings): LanguageModel { + const { provider, model, apiKeys, baseUrl } = settings; + const apiKey = apiKeys?.[provider as keyof ProviderApiKeys] || ""; + + switch (provider) { + case "openai": { + const openai = createOpenAI({ apiKey }); + return openai(model); + } + + case "anthropic": { + const anthropic = createAnthropic({ apiKey }); + return anthropic(model); + } + + case "xai": { + const xai = createXai({ apiKey }); + return xai(model); + } + + case "google": { + const google = createGoogleGenerativeAI({ apiKey }); + return google(model); + } + + case "mistral": { + const mistral = createMistral({ apiKey }); + return mistral(model); + } + + case "groq": { + const groq = createGroq({ apiKey }); + return groq(model); + } + + case "deepseek": { + // DeepSeek uses OpenAI-compatible API (Chat Completions) + const deepseek = createOpenAI({ + apiKey, + baseURL: PROVIDER_BASE_URLS.deepseek, + }); + return deepseek.chat(model); + } + + case "openrouter": { + // OpenRouter uses OpenAI-compatible API but doesn't support the Responses API + // Use .chat() to force Chat Completions API instead + const openrouter = createOpenAI({ + apiKey, + baseURL: PROVIDER_BASE_URLS.openrouter, + }); + return openrouter.chat(model); + } + + case "ollama": { + // Ollama uses OpenAI-compatible API (Chat Completions) + const ollamaUrl = baseUrl || "http://localhost:11434/v1"; + const ollama = createOpenAI({ + apiKey: "ollama", // Ollama doesn't require a real API key + baseURL: ollamaUrl, + }); + return ollama.chat(model); + } + + case "custom": { + // Custom OpenAI-compatible endpoint (Chat Completions) + if (!baseUrl) { + throw new Error("Custom provider requires a base URL"); + } + const custom = createOpenAI({ + apiKey, + baseURL: baseUrl, + }); + return custom.chat(model); + } + + default: + throw new Error(`Unsupported provider: ${provider}`); + } +} + +// ============================================================================= +// Chat Streaming +// ============================================================================= + +export interface StreamChatOptions { + messages: CoreMessage[]; + settings: AgentSettings; + tools?: ToolSet; + systemPrompt?: string; + abortSignal?: AbortSignal; + /** Max tool steps before stopping (default: 10, use higher for service supervision) */ + maxSteps?: number; +} + +export interface StreamChatResult { + textStream: AsyncIterable; + fullStream: AsyncIterable>; + fullText: Promise; +} + +/** + * Default system prompt for the agent + */ +const DEFAULT_SYSTEM_PROMPT = `You are ServiceAgent, an autonomous AI assistant built into a Windows desktop repair toolkit called RustService. You help computer repair technicians diagnose and fix Windows computers. + +## Environment +- **OS**: Windows 10/11 (you are running ON the target machine) +- **Shell**: PowerShell 5.1+ (use PowerShell syntax ONLY, never bash/Linux) +- **Context**: You are a portable tool running from a USB drive or local install +- **User**: A computer repair technician who needs fast, accurate system work + +## Core Behavior + +You are an AGENTIC assistant. This means: +1. **Complete tasks fully** - Don't stop after one step. Keep going until the goal is achieved. +2. **Chain tools together** - Multi-step tasks should flow: diagnose โ†’ analyze โ†’ fix โ†’ verify. +3. **Be autonomous** - Make decisions and act. Don't ask "should I proceed?" - just do it. +4. **Learn from errors** - If a command fails, analyze the error and try a different approach immediately. +5. **Explain as you go** - Brief explanations before each action, detailed analysis after results. + +## โš ๏ธ CRITICAL: Sequential Execution + +**NEVER run multiple commands simultaneously.** You MUST: +- Execute ONE command at a time +- Wait for and analyze the result before deciding the next action +- Only call ONE tool per response step +- Think through what you need BEFORE running a command + +If you need to run multiple commands, do them one-by-one across multiple steps. After each result, explain what you found and what you'll do next. + +## Available Tools + +### execute_command +Execute PowerShell commands. The user approves before execution. +- ALWAYS use PowerShell syntax: Get-ChildItem (not ls), Get-Process (not ps), Select-Object (not select) +- Chain commands with semicolons (;) not && +- Use Format-Table -AutoSize for readable tabular output +- Quote paths with spaces: "$env:USERPROFILE\\Downloads" +- Use -ErrorAction SilentlyContinue when checking things that might not exist +- For large outputs, pipe to Select-Object -First N to limit results + +### read_file +Read text file contents. Great for logs, configs, scripts. + +### write_file +Create or overwrite files. Requires user approval. + +### list_dir +List directory contents with name, type, and size. + +### move_file / copy_file +Move, rename, or copy files. Requires approval. + +### search_web +Search the internet for solutions, documentation, error fixes. + +### list_programs +List portable tools in the programs folder. + +### list_instruments / run_instrument +List and run custom technician scripts. + +### get_system_info +Get detailed hardware/OS info (CPU, RAM, disks, GPU, network). + +## Thinking Process + +For every task, follow this pattern: +1. **Assess** - What do I need to accomplish? What information do I need first? +2. **Plan** - What's the sequence of steps? Prioritize gathering info before making changes. +3. **Execute** - Run ONE command, analyze the result. +4. **Evaluate** - Did it work? What did I learn? What's next? +5. **Report** - Summarize findings clearly when the task is complete. + +## PowerShell Quick Reference +\`\`\`powershell +# System info +Get-ComputerInfo | Select-Object CsName, OsName, OsArchitecture, OsBuildNumber +systeminfo | Select-String "OS Name|Total Physical Memory|System Boot Time" + +# Disk health +Get-PhysicalDisk | Select-Object FriendlyName, MediaType, HealthStatus, Size +Get-Volume | Select-Object DriveLetter, FileSystemLabel, SizeRemaining, Size | Format-Table -AutoSize + +# Processes & services +Get-Process | Sort-Object CPU -Descending | Select-Object -First 15 Name, CPU, WorkingSet64 +Get-Service | Where-Object {$_.Status -eq "Running"} | Select-Object -First 20 + +# Network +Get-NetIPAddress -AddressFamily IPv4 | Select-Object InterfaceAlias, IPAddress +Test-NetConnection -ComputerName 8.8.8.8 -InformationLevel Quiet + +# Files +Get-ChildItem -Path "$env:USERPROFILE\\Downloads" -File | Sort-Object LastWriteTime -Descending | Select-Object Name, @{N="Size(MB)";E={[math]::Round($_.Length/1MB,2)}}, LastWriteTime -First 15 | Format-Table -AutoSize + +# Temp cleanup +Get-ChildItem "$env:TEMP" -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum +\`\`\` + +## Response Formatting +- Use **markdown tables** when presenting structured data (files, processes, disks, etc.) +- Use **code blocks** for commands and outputs +- Use **bold** for important findings +- Keep explanations concise but informative +- Use emoji sparingly for visual categorization (๐Ÿ“ folders, โš ๏ธ warnings, โœ… success) + +## IMPORTANT RULES +- ALWAYS use tools when asked to perform actions - don't just describe what you would do +- Keep going until the task is DONE - one tool call is rarely enough +- If a command fails with a syntax error, FIX IT and retry immediately +- When showing file listings or system data, format as a clean markdown table +- Never apologize excessively - just fix the issue and move on +- NEVER run more than ONE tool call per response step + +## SERVICE AUTOMATION + +You can run diagnostic and maintenance services on the system. Services are modular tools (disk checks, malware scans, system file repair, etc.) that execute sequentially in a queue. + +### Running Services +1. **Discover**: Use \`list_services\` to see available services and \`list_service_presets\` for preset bundles +2. **Validate**: Use \`check_service_requirements\` to verify required programs are installed +3. **Propose**: Describe the queue you'll run and WHY each service is included +4. **Execute**: Use \`run_service_queue\` to start the run (user must approve) +5. **Supervise**: You'll receive updates as each service completes โ€” monitor for issues +6. **Intervene**: If a service fails or reports critical findings, use \`pause_service\` and investigate +7. **Report**: After completion, review all findings, write analysis, and generate a PDF + +### Supervision Protocol +When monitoring a running service queue: +- After each service completes, you'll receive its results as a service update +- Briefly acknowledge each result (1-2 sentences) +- If severity is **error** or **critical**: explain the issue, consider pausing to investigate +- If a service fails entirely: note it and decide whether remaining services should continue +- After all services complete: do a comprehensive review + +### Report Writing +After a service run completes: +1. Call \`get_report_statistics\` for an overview +2. Review each service's findings with \`get_service_report\` +3. Write per-service analysis with \`set_service_analysis\` (plain language, actionable) +4. Edit findings if needed with \`edit_finding\` (make customer-friendly) +5. Write an executive summary with \`set_report_summary\` +6. Set a health score with \`set_health_score\` (0-100) +7. Generate the final report with \`generate_report_pdf\` +8. Present the PDF path to the user with key findings summarized + +### Symptom-to-Service Mapping +When users describe problems, map their symptoms to appropriate services: +- **Slow/laggy**: disk-space, bleachbit, sfc, dism, drivecleanup +- **Crashes/BSOD**: chkdsk, sfc, dism, driver-audit, smartctl +- **Virus/malware**: kvrt-scan, adwcleaner, stinger, bleachbit +- **Network issues**: ping-test, speedtest, network-config +- **Battery problems**: battery-report, energy-report +- **Disk issues**: disk-space, smartctl, chkdsk, bleachbit, drivecleanup +- **General checkup**: ping-test, disk-space, sfc, dism, driver-audit, smartctl, network-config`; + +/** + * Stream a chat response from the AI provider + */ +export async function streamChat( + options: StreamChatOptions, +): Promise { + const { messages, settings, tools, abortSignal, maxSteps = 10 } = options; + + let { systemPrompt } = options; + if (!systemPrompt) + systemPrompt = settings.systemPrompt || DEFAULT_SYSTEM_PROMPT; + + // Fetch dynamic context + try { + const instruments = await invoke>( + "list_instruments", + ).catch(() => []); + + let dynamicContext = ""; + + if (instruments && instruments.length > 0) { + dynamicContext += `\n\n## AVAILABLE CUSTOM INSTRUMENTS\nYou can run these special tools by name using 'run_instrument':\n`; + instruments.forEach((i) => { + dynamicContext += `- **${i.name}** (.${i.extension}): ${i.description}\n`; + }); + } + + if (dynamicContext) { + systemPrompt = (systemPrompt || DEFAULT_SYSTEM_PROMPT) + dynamicContext; + } + } catch (error) { + console.warn("Failed to load dynamic agent context:", error); + } + + const model = createProviderModel(settings); + + // Sanitize messages to ensure compatible format: + // SDK streams tool-results with input/output but expects result/isError when consuming + const sanitizedMessages = sanitizeMessagesForSDK(messages); + + const result = streamText({ + model, + system: systemPrompt, + messages: sanitizedMessages, + // Only pass tools if there are any defined - some models don't support tools + ...(tools && Object.keys(tools).length > 0 ? { tools } : {}), + // Enable multi-step tool calling - configurable step limit + // Higher limit used during service supervision (30 steps) + stopWhen: stepCountIs(maxSteps), + abortSignal, + }); + + return { + textStream: result.textStream, + fullStream: result.fullStream, + fullText: result.text, + }; +} + +/** + * Sanitize messages for SDK compatibility + * The SDK now uses ModelMessage format with ToolResultPart requiring 'output' field + * with LanguageModelV2ToolResultOutput structure + */ +function sanitizeMessagesForSDK(messages: CoreMessage[]): CoreMessage[] { + return messages.map((msg) => { + if (msg.role === "tool" && Array.isArray(msg.content)) { + return { + ...msg, + content: msg.content.map((part: any) => { + if (part.type === "tool-result") { + // Extract the raw result value + const rawOutput = part.output ?? part.result; + let resultValue: string; + let isError = false; + + if (rawOutput && typeof rawOutput === "object") { + // Check for error status or isError flag + isError = part.isError ?? rawOutput.status === "error"; + + if ("output" in rawOutput) { + // Unwrap { status, output } format + resultValue = + typeof rawOutput.output === "string" + ? rawOutput.output + : JSON.stringify(rawOutput.output); + } else if ("error" in rawOutput) { + resultValue = + typeof rawOutput.error === "string" + ? rawOutput.error + : JSON.stringify(rawOutput.error); + isError = true; + } else { + resultValue = JSON.stringify(rawOutput); + } + } else { + resultValue = String(rawOutput ?? ""); + } + + // Return in ModelMessage ToolResultPart format with LanguageModelV2ToolResultOutput + return { + type: "tool-result", + toolCallId: part.toolCallId, + toolName: part.toolName, + output: isError + ? { type: "error-text" as const, value: resultValue } + : { type: "text" as const, value: resultValue }, + }; + } + return part; + }), + }; + } + return msg; + }); +} + +/** + * Convert local message format to CoreMessage format + */ +export function convertToCoreMessages( + messages: Array<{ role: "user" | "assistant"; content: string }>, +): CoreMessage[] { + return messages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); +} diff --git a/src/lib/agent-heartbeat.ts b/src/lib/agent-heartbeat.ts new file mode 100644 index 0000000..b356fdf --- /dev/null +++ b/src/lib/agent-heartbeat.ts @@ -0,0 +1,73 @@ +/** + * Agent Heartbeat + * + * Watchdog that tracks agent loop activity and detects stalls. + * Checks every 5 seconds โ€” if no stream event for 30s, marks as `stalled`. + */ + +export type HeartbeatStatus = 'idle' | 'active' | 'stalled'; + +export type HeartbeatCallback = (status: HeartbeatStatus) => void; + +const CHECK_INTERVAL_MS = 5_000; +const STALL_THRESHOLD_MS = 30_000; + +export class AgentHeartbeat { + private _status: HeartbeatStatus = 'idle'; + private _lastPing = 0; + private _intervalId: ReturnType | null = null; + private _onStatusChange: HeartbeatCallback | null = null; + + /** Current heartbeat status. */ + get status(): HeartbeatStatus { + return this._status; + } + + /** Register a callback for status changes. */ + onStatusChange(cb: HeartbeatCallback) { + this._onStatusChange = cb; + } + + /** Start monitoring. Call at the beginning of `runAgentLoop`. */ + start() { + this._lastPing = Date.now(); + this._setStatus('active'); + + // Clear any existing interval + if (this._intervalId !== null) { + clearInterval(this._intervalId); + } + + this._intervalId = setInterval(() => { + if (this._status === 'idle') return; + + const elapsed = Date.now() - this._lastPing; + if (elapsed >= STALL_THRESHOLD_MS && this._status !== 'stalled') { + this._setStatus('stalled'); + } + }, CHECK_INTERVAL_MS); + } + + /** Record activity. Call on every stream event. */ + ping() { + this._lastPing = Date.now(); + if (this._status === 'stalled') { + this._setStatus('active'); + } + } + + /** Stop monitoring. Call on all exit paths of `runAgentLoop`. */ + stop() { + if (this._intervalId !== null) { + clearInterval(this._intervalId); + this._intervalId = null; + } + this._setStatus('idle'); + } + + private _setStatus(status: HeartbeatStatus) { + if (this._status === status) return; + this._status = status; + this._onStatusChange?.(status); + } +} diff --git a/src/lib/agent-loop-queue.ts b/src/lib/agent-loop-queue.ts new file mode 100644 index 0000000..38749b6 --- /dev/null +++ b/src/lib/agent-loop-queue.ts @@ -0,0 +1,161 @@ +/** + * Agent Loop Queue + * + * Ensures only one `runAgentLoop` executes at a time. + * Queued requests are coalesced โ€” multiple rapid service updates + * become one combined message โ€” and processed sequentially. + */ + +import type { CoreMessage } from 'ai'; + +export interface LoopOptions { + /** How many more auto-continue turns the agent may take (default: 50). */ + turnsRemaining?: number; + reuseMessageId?: string | null; + /** Internal: pass parts directly to avoid React state race. */ + _currentParts?: unknown[]; + _currentContent?: string; +} + +export interface LoopRequest { + history: CoreMessage[]; + options?: LoopOptions; + /** When set, this request is a service update that can be merged with others. */ + serviceUpdate?: { + content: string; + }; +} + +type RunAgentLoopFn = ( + history: CoreMessage[], + options?: LoopOptions, +) => Promise; + +export class AgentLoopQueue { + private _busy = false; + private _queue: LoopRequest[] = []; + private _runFn: RunAgentLoopFn; + + constructor(runFn: RunAgentLoopFn) { + this._runFn = runFn; + } + + /** Whether a loop is currently executing. */ + get busy(): boolean { + return this._busy; + } + + /** Number of pending (queued) requests. */ + get pending(): number { + return this._queue.length; + } + + /** Update the run function (e.g. when the callback reference changes). */ + setRunFn(fn: RunAgentLoopFn) { + this._runFn = fn; + } + + /** + * Add a request to the queue. + * If idle, starts processing immediately. + * If busy, coalesces service updates and queues the request. + */ + enqueue(request: LoopRequest) { + if (!this._busy) { + this._processRequest(request); + } else { + this._queue.push(request); + } + } + + /** + * Merge queued service update requests into a single request. + * Non-service requests pass through unchanged. + * Returns the coalesced queue. + */ + private coalesceQueue(): LoopRequest[] { + if (this._queue.length <= 1) return this._queue; + + const coalesced: LoopRequest[] = []; + let pendingServiceUpdates: LoopRequest[] = []; + + for (const req of this._queue) { + if (req.serviceUpdate) { + pendingServiceUpdates.push(req); + } else { + // Flush any accumulated service updates before a non-service request + if (pendingServiceUpdates.length > 0) { + coalesced.push(this._mergeServiceUpdates(pendingServiceUpdates)); + pendingServiceUpdates = []; + } + coalesced.push(req); + } + } + + // Flush remaining service updates + if (pendingServiceUpdates.length > 0) { + coalesced.push(this._mergeServiceUpdates(pendingServiceUpdates)); + } + + return coalesced; + } + + /** + * Merge multiple service update requests into one combined request. + * Uses the latest history and combines all update content. + */ + private _mergeServiceUpdates(updates: LoopRequest[]): LoopRequest { + if (updates.length === 1) return updates[0]; + + // Combine all service update content into one message + const combinedContent = updates + .map(u => u.serviceUpdate!.content) + .join('\n\n---\n\n'); + + // Use the latest history as the base (it's the most up-to-date) + const latestHistory = updates[updates.length - 1].history; + + // Replace the last user message (which would be a service update) with the combined one + const baseHistory = latestHistory.slice(0, -1); + const combinedMsg: CoreMessage = { + role: 'user', + content: `[SERVICE UPDATE โ€” ${updates.length} services completed]\n\n${combinedContent}`, + }; + + return { + history: [...baseHistory, combinedMsg], + options: updates[updates.length - 1].options, + serviceUpdate: { content: combinedContent }, + }; + } + + private async _processRequest(request: LoopRequest) { + this._busy = true; + try { + await this._runFn(request.history, request.options); + } catch (err) { + console.error('[AgentLoopQueue] Error during loop execution:', err); + } finally { + this._busy = false; + this._processNext(); + } + } + + private _processNext() { + if (this._queue.length === 0) return; + + // Coalesce before processing + const coalesced = this.coalesceQueue(); + this._queue = coalesced.slice(1); + const next = coalesced[0]; + + if (next) { + this._processRequest(next); + } + } + + /** Clear all pending requests (e.g. on abort/reset). */ + clear() { + this._queue = []; + } +} diff --git a/src/lib/agent-tools.ts b/src/lib/agent-tools.ts new file mode 100644 index 0000000..e0a88f7 --- /dev/null +++ b/src/lib/agent-tools.ts @@ -0,0 +1,827 @@ +/** + * Agent Tools for Vercel AI SDK + * + * Defines the tools available to the AI agent for system operations, + * file management, and web search. + * + * Tools without an execute function are "client-side" HITL tools - + * they will be rendered on the frontend for user interaction/approval. + */ + +import { tool, type ToolSet } from 'ai'; +import { z } from 'zod'; +import { invoke } from '@tauri-apps/api/core'; +import type { SearchResult, FileEntry, Instrument } from '@/types/agent'; +import type { + ServiceDefinition, + ServicePreset, + ServiceReport, + ReportStatistics, + ServiceRunState, +} from '@/types/service'; + +// ============================================================================= +// Common Output Schemas +// ============================================================================= + +const commandResultSchema = z.object({ + status: z.enum(['success', 'error']), + output: z.string().optional(), + error: z.string().optional(), + exitCode: z.number().optional(), +}); + +const fileResultSchema = z.object({ + status: z.enum(['success', 'error']), + output: z.string().optional(), + error: z.string().optional(), + path: z.string().optional(), +}); + +// ============================================================================= +// HITL Tools (Client-Side - No Execute Function) +// ============================================================================= + +export const executeCommandTool = tool({ + description: `Execute a PowerShell command on the Windows system. The user will see and approve the command before it runs. + +IMPORTANT POWERSHELL RULES: +- Use full cmdlet names: Get-ChildItem (not ls/dir), Get-Process (not ps), Select-Object (not select) +- Chain with semicolons (;) not && +- Pipe to Format-Table -AutoSize or Select-Object for readable output +- Quote paths with spaces: "$env:USERPROFILE\\Downloads" +- Use -ErrorAction SilentlyContinue when checking things that may not exist +- Prefer structured output over raw strings`, + inputSchema: z.object({ + command: z.string().describe('The PowerShell command to execute'), + reason: z.string().describe('Brief explanation of what this command does and why'), + }), + outputSchema: commandResultSchema, +}); + +export const writeFileTool = tool({ + description: 'Write content to a file. Creates the file if it does not exist, overwrites if it does. The user will approve this action.', + inputSchema: z.object({ + path: z.string().describe('Full absolute path to the file'), + content: z.string().describe('Content to write to the file'), + }), + outputSchema: fileResultSchema, +}); + +export const moveFileTool = tool({ + description: 'Move or rename a file or directory. The user will approve this action.', + inputSchema: z.object({ + src: z.string().describe('Source path'), + dest: z.string().describe('Destination path'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + output: z.string().optional(), + error: z.string().optional(), + }), +}); + +export const copyFileTool = tool({ + description: 'Copy a file or directory. The user will approve this action.', + inputSchema: z.object({ + src: z.string().describe('Source path'), + dest: z.string().describe('Destination path'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + output: z.string().optional(), + error: z.string().optional(), + }), +}); + +// ============================================================================= +// Server-Side Tools (Auto-Execute) +// ============================================================================= + +export const searchWebTool = tool({ + description: 'Search the web for information, documentation, error solutions, or guides.', + inputSchema: z.object({ + query: z.string().describe('The search query - be specific, include error codes when applicable'), + provider: z.enum(['tavily', 'searxng']).optional().describe('Search provider'), + }), + execute: async ({ query, provider = 'tavily' }) => { + try { + const settings = await invoke<{ agent: { tavilyApiKey?: string; searxngUrl?: string } }>('get_settings'); + let results: SearchResult[]; + + if (provider === 'tavily' && settings.agent.tavilyApiKey) { + results = await invoke('search_tavily', { query, api_key: settings.agent.tavilyApiKey }); + } else if (provider === 'searxng' && settings.agent.searxngUrl) { + results = await invoke('search_searxng', { query, instance_url: settings.agent.searxngUrl }); + } else { + return { status: 'error', error: 'No search provider configured. Set up Tavily or SearXNG in Settings โ†’ AI Agent โ†’ Web Search.' }; + } + + return { status: 'success', results: results.map(r => ({ title: r.title, url: r.url, snippet: r.snippet })) }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const readFileTool = tool({ + description: 'Read the contents of a text file with optional line numbers and pagination. Use for examining logs, configs, scripts, or any text file.', + inputSchema: z.object({ + path: z.string().describe('Full absolute path to the file'), + offset: z.number().optional().describe('Line number to start from (0-indexed)'), + limit: z.number().optional().describe('Maximum number of lines to read'), + lineNumbers: z.boolean().optional().describe('Include line numbers in output (default: true)'), + }), + execute: async ({ path, offset, limit, lineNumbers }) => { + try { + const result = await invoke<{ content: string; totalLines: number; hasMore: boolean }>('agent_read_file', { + path, + offset, + limit, + line_numbers: lineNumbers ?? true + }); + return { + status: 'success', + content: result.content, + totalLines: result.totalLines, + hasMore: result.hasMore, + }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const editFileTool = tool({ + description: `Replace old_string with new_string in a file. The old_string must be unique in the file unless all=true is specified. +Use this for targeted edits instead of rewriting entire files. Always read the file first to get the exact string to replace.`, + inputSchema: z.object({ + path: z.string().describe('Full absolute path to the file'), + oldString: z.string().describe('The exact string to replace (must be unique in file unless all=true)'), + newString: z.string().describe('The replacement string'), + all: z.boolean().optional().describe('Replace all occurrences (default: false)'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + replacements: z.number().optional(), + message: z.string().optional(), + error: z.string().optional(), + }), +}); + +export const grepTool = tool({ + description: `Search for a regex pattern across files in a directory. Returns matching lines with file paths and line numbers. +Use this to find code patterns, error messages, or specific content across multiple files.`, + inputSchema: z.object({ + pattern: z.string().describe('Regex pattern to search for'), + path: z.string().optional().describe('Directory path to search (default: current working directory)'), + filePattern: z.string().optional().describe('Glob pattern to filter files (e.g., "*.ts", "*.rs")'), + maxResults: z.number().optional().describe('Maximum number of results (default: 50)'), + }), + execute: async ({ pattern, path, filePattern, maxResults }) => { + try { + const results = await invoke>('agent_grep', { + pattern, + path, + file_pattern: filePattern, + max_results: maxResults ?? 50 + }); + return { + status: 'success', + results, + count: results.length, + }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const globTool = tool({ + description: `Find files matching a glob pattern, sorted by modification time (newest first). +Use this to discover files by pattern, e.g., "*.log", "src/**/*.ts", "config.*".`, + inputSchema: z.object({ + pattern: z.string().describe('Glob pattern (e.g., "*.txt", "src/**/*.rs")'), + path: z.string().optional().describe('Base directory path (default: current working directory)'), + limit: z.number().optional().describe('Maximum number of results (default: 100)'), + }), + execute: async ({ pattern, path, limit }) => { + try { + const results = await invoke>('agent_glob', { + pattern, + path, + limit: limit ?? 100 + }); + return { + status: 'success', + files: results, + count: results.length, + }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const listDirTool = tool({ + description: 'List files and directories at a given path. Returns name, type, and size.', + inputSchema: z.object({ + path: z.string().describe('Absolute path to list. Use C:\\Users\\ for home, C:\\ for root.'), + }), + execute: async ({ path }) => { + try { + const entries = await invoke('agent_list_dir', { path }); + return { + status: 'success', + entries: entries.map(e => ({ name: e.name, path: e.path, type: e.is_dir ? 'dir' : 'file', size: e.size })), + }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const listProgramsTool = tool({ + description: 'Get an overview of all portable programs installed in data/programs (executables scanned recursively). Use only when you need to see everything at once. For locating a specific tool, prefer find_exe.', + inputSchema: z.object({}), + execute: async () => { + try { + const programs = await invoke>>('list_agent_programs'); + return { status: 'success', programs: programs.map(p => ({ name: p.name, path: p.path, executables: p.executables })) }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const findExeTool = tool({ + description: `Preferred tool for locating a specific CLI executable. Searches data/programs recursively. +Use find_exe("smartctl") instead of list_programs when you know what tool you need. +Returns full absolute paths of matches. If empty, the program is not installed. +Set searchPath=true to also check system PATH via where.exe.`, + inputSchema: z.object({ + query: z.string().describe('Executable name or keyword to search for (e.g. "smartctl", "ffmpeg", "rclone")'), + searchPath: z.boolean().optional().describe('Also check system PATH via where.exe (default: false)'), + }), + execute: async ({ query, searchPath }) => { + try { + const matches = await invoke('agent_find_exe', { query, search_path: searchPath ?? false }); + return { status: 'success' as const, matches, found: matches.length > 0 }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const listInstrumentsTool = tool({ + description: 'List available custom instruments (scripts) that can be run with run_instrument.', + inputSchema: z.object({}), + execute: async () => { + try { + const instruments = await invoke('list_instruments'); + return { status: 'success', instruments: instruments.map(i => ({ name: i.name, description: i.description, extension: i.extension })) }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const runInstrumentTool = tool({ + description: 'Run a custom instrument (script) by name.', + inputSchema: z.object({ + name: z.string().describe('Name of the instrument to run'), + args: z.string().optional().describe('Arguments to pass'), + }), + execute: async ({ name, args }) => { + try { + const instruments = await invoke('list_instruments'); + const instrument = instruments.find(i => i.name.toLowerCase() === name.toLowerCase()); + if (!instrument) return { status: 'error', error: `Instrument '${name}' not found. Use list_instruments to see available.` }; + + let command = ''; + if (instrument.extension === 'ps1') command = `powershell -ExecutionPolicy Bypass -File "${instrument.path}" ${args || ''}`; + else if (['bat', 'cmd', 'exe'].includes(instrument.extension)) command = `"${instrument.path}" ${args || ''}`; + else if (instrument.extension === 'py') command = `python "${instrument.path}" ${args || ''}`; + else if (instrument.extension === 'js') command = `node "${instrument.path}" ${args || ''}`; + + const result = await invoke<{ id: string; status: string; output?: string }>('queue_agent_command', { command, reason: `Running instrument: ${name}` }); + return result.status === 'executed' + ? { status: 'success', output: result.output || 'Executed successfully' } + : { status: 'pending', commandId: result.id, message: 'Waiting for approval' }; + } catch (error) { + return { status: 'error', error: String(error) }; + } + }, +}); + +export const getSystemInfoTool = tool({ + description: `Get system information (OS, CPU, RAM, disks, GPU, network). Specify sections to avoid loading unnecessary data and save tokens. +Examples: sections=["disk"] for disk space, sections=["memory"] for RAM, sections=["os","cpu"] for hardware overview. +Omit sections to get everything.`, + inputSchema: z.object({ + sections: z.array(z.enum(['os', 'cpu', 'memory', 'disk', 'network'])).optional() + .describe('Specific sections to retrieve. Omit for all. Use ["disk"] for disk tasks, ["memory"] for RAM diagnostics, etc.'), + }), + execute: async ({ sections }) => { + try { + const info = await invoke>('get_system_info'); + if (!sections || sections.length === 0) { + return { status: 'success' as const, info }; + } + const keyMap: Record = { + os: 'os', + cpu: 'cpu', + memory: 'memory', + disk: 'disks', + network: 'networks', + }; + const filtered: Record = {}; + for (const s of sections) { + const key = keyMap[s]; + if (key && info[key] !== undefined) filtered[key] = info[key]; + } + return { status: 'success' as const, info: filtered }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +// ============================================================================= +// Service Tools โ€” Server-Side (Auto-Execute) +// ============================================================================= + +export const listServicesTool = tool({ + description: `List all available diagnostic and maintenance services. Returns service ID, name, description, category, estimated duration, and required programs. Use this to understand what services are available before building a queue.`, + inputSchema: z.object({}), + execute: async () => { + try { + const definitions = await invoke('get_service_definitions'); + return { + status: 'success' as const, + services: definitions.map(d => ({ + id: d.id, + name: d.name, + description: d.description, + category: d.category, + estimatedDurationSecs: d.estimatedDurationSecs, + requiredPrograms: d.requiredPrograms, + options: d.options.map(o => ({ id: o.id, label: o.label, type: o.optionType, default: o.defaultValue })), + })), + }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const listServicePresetsTool = tool({ + description: `List all service presets (Diagnostics, General, Complete, Custom, and user-created). Each preset contains a pre-configured list of services. Use this to offer the user preset-based runs.`, + inputSchema: z.object({}), + execute: async () => { + try { + const presets = await invoke('get_service_presets'); + return { + status: 'success' as const, + presets: presets.map(p => ({ + id: p.id, + name: p.name, + description: p.description, + serviceCount: p.services.filter(s => s.enabled).length, + services: p.services.map(s => ({ serviceId: s.serviceId, enabled: s.enabled })), + })), + }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const checkServiceRequirementsTool = tool({ + description: `Check if the required external programs are available for a list of services. Returns which services have missing requirements. Use this before running services to verify they can execute.`, + inputSchema: z.object({ + service_ids: z.array(z.string()).describe('Array of service IDs to check'), + }), + execute: async ({ service_ids }) => { + try { + const missing = await invoke>('validate_service_requirements', { + service_ids, + }); + const allClear = Object.keys(missing).length === 0; + return { + status: 'success' as const, + allRequirementsMet: allClear, + missingPrograms: missing, + }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const getServiceStatusTool = tool({ + description: `Get the current service run state. Returns whether a run is active, paused, and the current report with progress. Use this to check on a running service.`, + inputSchema: z.object({}), + execute: async () => { + try { + const state = await invoke('get_service_run_state'); + return { + status: 'success' as const, + isRunning: state.isRunning, + isPaused: state.isPaused, + currentServiceIndex: state.currentReport?.currentServiceIndex, + totalServices: state.currentReport?.queue.filter(q => q.enabled).length, + currentServiceId: state.currentReport?.currentServiceIndex != null + ? state.currentReport?.queue.filter(q => q.enabled)[state.currentReport.currentServiceIndex]?.serviceId + : undefined, + completedResults: state.currentReport?.results.length ?? 0, + reportStatus: state.currentReport?.status, + }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const getServiceReportTool = tool({ + description: `Get a saved service report by its ID. Returns the full report with all findings, results, and metadata. Use this after a run completes or to review past reports.`, + inputSchema: z.object({ + report_id: z.string().describe('The report ID to retrieve'), + }), + execute: async ({ report_id }) => { + try { + const report = await invoke('get_service_report', { report_id }); + return { + status: 'success' as const, + report: { + id: report.id, + startedAt: report.startedAt, + completedAt: report.completedAt, + status: report.status, + totalDurationMs: report.totalDurationMs, + agentSummary: report.agentSummary, + healthScore: report.healthScore, + results: report.results.map(r => ({ + serviceId: r.serviceId, + success: r.success, + error: r.error, + durationMs: r.durationMs, + findingsCount: r.findings.length, + findings: r.findings.map(f => ({ + severity: f.severity, + title: f.title, + description: f.description, + recommendation: f.recommendation, + })), + agentAnalysis: r.agentAnalysis, + })), + }, + }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const getReportStatisticsTool = tool({ + description: `Get computed statistics for a service report: pass/fail counts, severity breakdown, health score, duration metrics. Use this for a high-level overview before writing a summary.`, + inputSchema: z.object({ + report_id: z.string().describe('The report ID to analyze'), + }), + execute: async ({ report_id }) => { + try { + const stats = await invoke('get_report_statistics', { report_id }); + return { status: 'success' as const, statistics: stats }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const editFindingTool = tool({ + description: `Edit an existing finding in a saved report. Can update severity, title, description, or recommendation. Use this to make findings more customer-friendly or correct inaccuracies.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + service_id: z.string().describe('Service ID within the report'), + finding_index: z.number().describe('Zero-based index of the finding to edit'), + severity: z.enum(['info', 'success', 'warning', 'error', 'critical']).optional().describe('New severity level'), + title: z.string().optional().describe('New title'), + description: z.string().optional().describe('New description'), + recommendation: z.string().optional().describe('New recommendation'), + }), + execute: async ({ report_id, service_id, finding_index, severity, title, description, recommendation }) => { + try { + await invoke('edit_report_finding', { + report_id, service_id, finding_index, + severity: severity ?? null, + title: title ?? null, + description: description ?? null, + recommendation: recommendation ?? null, + }); + return { status: 'success' as const, message: 'Finding updated' }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const addFindingTool = tool({ + description: `Add a new finding to a service result in a report. Use this when you discover something during intervention that isn't captured in the original results.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + service_id: z.string().describe('Service ID to add the finding to'), + severity: z.enum(['info', 'success', 'warning', 'error', 'critical']).describe('Finding severity'), + title: z.string().describe('Short finding title'), + description: z.string().describe('Detailed finding description'), + recommendation: z.string().optional().describe('Recommended action'), + }), + execute: async ({ report_id, service_id, severity, title, description, recommendation }) => { + try { + await invoke('add_report_finding', { + report_id, service_id, severity, title, description, + recommendation: recommendation ?? null, + }); + return { status: 'success' as const, message: 'Finding added' }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const removeFindingTool = tool({ + description: `Remove a finding from a service result in a report by index. Use sparingly โ€” only to remove duplicate or irrelevant findings.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + service_id: z.string().describe('Service ID'), + finding_index: z.number().describe('Zero-based index of the finding to remove'), + }), + execute: async ({ report_id, service_id, finding_index }) => { + try { + await invoke('remove_report_finding', { report_id, service_id, finding_index }); + return { status: 'success' as const, message: 'Finding removed' }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const setReportSummaryTool = tool({ + description: `Set the executive summary on a report. Write a comprehensive, professional summary covering overall system health, key findings, and recommendations. This appears at the top of the report.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + summary: z.string().describe('Executive summary text (professional, comprehensive)'), + }), + execute: async ({ report_id, summary }) => { + try { + await invoke('set_report_summary', { report_id, summary }); + return { status: 'success' as const, message: 'Summary set' }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const setServiceAnalysisTool = tool({ + description: `Set agent analysis text for a specific service result. Write a brief interpretation of what the service found and what it means for the user.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + service_id: z.string().describe('Service ID to annotate'), + analysis: z.string().describe('Analysis text explaining the results'), + }), + execute: async ({ report_id, service_id, analysis }) => { + try { + await invoke('set_service_analysis', { report_id, service_id, analysis }); + return { status: 'success' as const, message: 'Analysis set' }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const setHealthScoreTool = tool({ + description: `Set the overall health score (0-100) on a report. Score guidelines: 90-100 excellent, 70-89 good, 50-69 fair, 30-49 poor, 0-29 critical.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID'), + score: z.number().min(0).max(100).describe('Health score 0-100'), + }), + execute: async ({ report_id, score }) => { + try { + await invoke('set_report_health_score', { report_id, score }); + return { status: 'success' as const, message: `Health score set to ${score}` }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +export const generateReportPdfTool = tool({ + description: `Generate a PDF report file from a completed service report. Returns the file path. Use this as the final step after editing findings and writing a summary.`, + inputSchema: z.object({ + report_id: z.string().describe('Report ID to generate PDF from'), + output_path: z.string().optional().describe('Custom output path (optional, defaults to data/reports/)'), + }), + execute: async ({ report_id, output_path }) => { + try { + const path = await invoke('generate_report_pdf', { + report_id, + output_path: output_path ?? null, + }); + return { status: 'success' as const, path, message: `Report PDF generated at ${path}` }; + } catch (error) { + return { status: 'error' as const, error: String(error) }; + } + }, +}); + +// ============================================================================= +// Service Tools โ€” HITL (Require Approval) +// ============================================================================= + +export const runServiceQueueTool = tool({ + description: `Start a service run with the specified queue. This is a significant operation that runs diagnostic/maintenance services on the system. The user must approve before execution begins. Provide a queue of service IDs with options, or use a preset. + +Build the queue by: +1. First call list_services to see available services +2. Call check_service_requirements to verify programs are installed +3. Build the queue array with service IDs, enabled flags, and options +4. Optionally set technician and customer names for business mode + +The run will execute sequentially. You will receive updates as each service completes.`, + inputSchema: z.object({ + queue: z.array(z.object({ + service_id: z.string(), + enabled: z.boolean(), + order: z.number(), + options: z.record(z.string(), z.unknown()).default({}), + })).describe('Service queue items'), + technician_name: z.string().optional().describe('Technician name (business mode)'), + customer_name: z.string().optional().describe('Customer name (business mode)'), + reason: z.string().describe('Brief explanation of why these services are being run'), + }), + outputSchema: z.object({ + status: z.enum(['started', 'error']), + report_id: z.string().optional(), + message: z.string().optional(), + error: z.string().optional(), + }), +}); + +export const pauseServiceTool = tool({ + description: `Pause the currently running service queue. Takes effect between services (the current service will finish first). Use this when you detect an issue that needs investigation before continuing.`, + inputSchema: z.object({ + reason: z.string().describe('Why you are pausing the run'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + message: z.string().optional(), + error: z.string().optional(), + }), +}); + +export const resumeServiceTool = tool({ + description: `Resume a paused service run. Use this after investigating and resolving an issue, or deciding the remaining services should continue.`, + inputSchema: z.object({ + reason: z.string().describe('Why it is safe to resume'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + message: z.string().optional(), + error: z.string().optional(), + }), +}); + +export const cancelServiceTool = tool({ + description: `Cancel the currently running service queue. Use this if a critical issue is found and the remaining services should not run.`, + inputSchema: z.object({ + reason: z.string().describe('Why you are cancelling the run'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error']), + message: z.string().optional(), + error: z.string().optional(), + }), +}); + +// ============================================================================= +// File Generation Tool (HITL) +// ============================================================================= + +export const generateFileTool = tool({ + description: `Generate a file with content and save it to the agent workspace. +Use this when you need to create reports, logs, scripts, configuration files, or any output file. +The file will be saved and the user will be able to download it from the chat. +Examples: creating diagnostic reports, exporting logs, generating scripts, saving analysis results.`, + inputSchema: z.object({ + filename: z.string().describe('Name for the file including extension (e.g., "report.txt", "script.ps1", "data.json")'), + content: z.string().describe('Full content to write to the file'), + description: z.string().describe('Brief description of what this file contains and why it was generated'), + mime_type: z.string().optional().describe('MIME type (optional, auto-detected from extension if not provided)'), + }), + outputSchema: z.object({ + status: z.enum(['success', 'error', 'pending']), + file_id: z.string().optional(), + path: z.string().optional(), + size: z.number().optional(), + error: z.string().optional(), + }), +}); + +// ============================================================================= +// Tool Collection & Helpers +// ============================================================================= + +export const agentTools = { + execute_command: executeCommandTool, + search_web: searchWebTool, + read_file: readFileTool, + edit_file: editFileTool, + grep: grepTool, + glob: globTool, + write_file: writeFileTool, + generate_file: generateFileTool, + list_dir: listDirTool, + move_file: moveFileTool, + copy_file: copyFileTool, + list_programs: listProgramsTool, + find_exe: findExeTool, + list_instruments: listInstrumentsTool, + run_instrument: runInstrumentTool, + get_system_info: getSystemInfoTool, + // Service tools + list_services: listServicesTool, + list_service_presets: listServicePresetsTool, + check_service_requirements: checkServiceRequirementsTool, + get_service_status: getServiceStatusTool, + get_service_report: getServiceReportTool, + get_report_statistics: getReportStatisticsTool, + edit_finding: editFindingTool, + add_finding: addFindingTool, + remove_finding: removeFindingTool, + set_report_summary: setReportSummaryTool, + set_service_analysis: setServiceAnalysisTool, + set_health_score: setHealthScoreTool, + generate_report_pdf: generateReportPdfTool, + run_service_queue: runServiceQueueTool, + pause_service: pauseServiceTool, + resume_service: resumeServiceTool, + cancel_service: cancelServiceTool, +} satisfies ToolSet; + +export const HITL_TOOLS = [ + 'execute_command', 'write_file', 'generate_file', 'move_file', 'copy_file', 'edit_file', + 'run_service_queue', 'pause_service', 'resume_service', 'cancel_service', +] as const; + +export function isHITLTool(toolName: string): boolean { + return HITL_TOOLS.includes(toolName as typeof HITL_TOOLS[number]); +} + +export function shouldRequireApproval(toolName: string, approvalMode: string): boolean { + if (!isHITLTool(toolName)) return false; + if (approvalMode === 'yolo') return false; + return true; +} + +export function getEnabledTools(settings: { searchProvider: string }): ToolSet { + const tools: ToolSet = { + execute_command: executeCommandTool, + read_file: readFileTool, + edit_file: editFileTool, + grep: grepTool, + glob: globTool, + write_file: writeFileTool, + generate_file: generateFileTool, + list_dir: listDirTool, + move_file: moveFileTool, + copy_file: copyFileTool, + list_programs: listProgramsTool, + find_exe: findExeTool, + list_instruments: listInstrumentsTool, + run_instrument: runInstrumentTool, + get_system_info: getSystemInfoTool, + // Service tools + list_services: listServicesTool, + list_service_presets: listServicePresetsTool, + check_service_requirements: checkServiceRequirementsTool, + get_service_status: getServiceStatusTool, + get_service_report: getServiceReportTool, + get_report_statistics: getReportStatisticsTool, + edit_finding: editFindingTool, + add_finding: addFindingTool, + remove_finding: removeFindingTool, + set_report_summary: setReportSummaryTool, + set_service_analysis: setServiceAnalysisTool, + set_health_score: setHealthScoreTool, + generate_report_pdf: generateReportPdfTool, + run_service_queue: runServiceQueueTool, + pause_service: pauseServiceTool, + resume_service: resumeServiceTool, + cancel_service: cancelServiceTool, + }; + + if (settings.searchProvider !== 'none') { + tools.search_web = searchWebTool; + } + + return tools; +} diff --git a/src/lib/ai-features.ts b/src/lib/ai-features.ts new file mode 100644 index 0000000..1270df4 --- /dev/null +++ b/src/lib/ai-features.ts @@ -0,0 +1,239 @@ +/** + * AI Features Utility Module + * + * Shared AI generation functions for targeted single-purpose AI features + * spread across the app. Uses the Vercel AI SDK with the user's configured + * provider from Settings โ†’ AI Agent. + * + * Features: + * - Programs Page: AI-powered semantic search + * - Scripts Page: AI script generation + * - Service Report: AI summary generation + */ + +import { generateObject } from 'ai'; +import { z } from 'zod'; +import { createProviderModel } from '@/lib/agent-chat'; +import type { AgentSettings } from '@/types/agent'; +import type { Program } from '@/types/programs'; +import type { ServiceReport, ServiceDefinition } from '@/types/service'; + +// ============================================================================= +// Configuration Check +// ============================================================================= + +/** + * Check if AI is configured with a valid provider and API key. + * Ollama doesn't require an API key. + */ +export function isAiConfigured(settings: AgentSettings): boolean { + if (!settings.provider || !settings.model) return false; + if (settings.provider === 'ollama') return true; + const key = settings.apiKeys?.[settings.provider as keyof typeof settings.apiKeys]; + return !!key && key.length > 0; +} + +// ============================================================================= +// Programs AI Search +// ============================================================================= + +export interface AiSearchResult { + programId: string; + reason: string; + relevance: number; +} + +/** + * Use AI to find the best program(s) matching a natural language query. + * Returns ranked results with relevance scores and explanations. + */ +export async function aiSearchPrograms( + programs: Program[], + query: string, + settings: AgentSettings, + abortSignal?: AbortSignal +): Promise { + if (!isAiConfigured(settings)) { + throw new Error('AI not configured. Set up a provider in Settings โ†’ AI Agent.'); + } + if (programs.length === 0) { + return []; + } + + const model = createProviderModel(settings); + + const programList = programs + .map((p) => `- ID: "${p.id}" | Name: "${p.name}" | Description: "${p.description}"`) + .join('\n'); + + const { object } = await generateObject({ + model, + schema: z.object({ + results: z.array( + z.object({ + programId: z.string().describe('The exact ID of the matching program'), + reason: z.string().describe('Brief explanation of why this program matches (1-2 sentences)'), + relevance: z.number().min(0).max(100).describe('Relevance score 0-100'), + }) + ), + }), + system: `You are a computer repair technician's assistant. Given a list of portable programs/tools and a user's need, pick the most relevant program(s). + +Rules: +- Only return programs that genuinely match the user's need +- Order by relevance (best match first) +- Relevance score: 90-100 = perfect match, 70-89 = good match, 50-69 = partial match, below 50 = don't include +- Only include programs with relevance >= 50 +- If no programs match, return an empty results array +- Keep reasons concise and practical`, + prompt: `Available programs:\n${programList}\n\nUser needs: "${query}"`, + abortSignal, + }); + + return object?.results ?? []; +} + +// ============================================================================= +// Scripts AI Writer +// ============================================================================= + +export interface AiGeneratedScript { + name: string; + description: string; + content: string; + runAsAdmin: boolean; +} + +/** + * Use AI to generate a PowerShell or CMD script based on a natural language description. + */ +export async function aiGenerateScript( + prompt: string, + scriptType: 'powershell' | 'cmd', + settings: AgentSettings, + abortSignal?: AbortSignal +): Promise { + if (!isAiConfigured(settings)) { + throw new Error('AI not configured. Set up a provider in Settings โ†’ AI Agent.'); + } + + const model = createProviderModel(settings); + + const { object } = await generateObject({ + model, + schema: z.object({ + name: z.string().describe('Short descriptive name for the script (3-6 words)'), + description: z.string().describe('Brief description of what the script does (1-2 sentences)'), + content: z.string().describe('The complete script content, ready to run'), + runAsAdmin: z.boolean().describe('Whether this script requires administrator privileges'), + }), + system: `You are an expert Windows systems administrator and computer repair technician. Generate ${scriptType === 'powershell' ? 'PowerShell' : 'CMD/Batch'} scripts for Windows 10/11. + +Rules: +- Write clean, well-commented scripts +- Include error handling where appropriate +- Use best practices for ${scriptType === 'powershell' ? 'PowerShell' : 'CMD/Batch'} scripting +- Set runAsAdmin to true only if the script genuinely needs elevated privileges (e.g., modifying system files, services, registry HKLM) +- The script should be complete and ready to execute +- Do NOT wrap the script content in markdown code fences +- Focus on practical, safe operations for computer repair/maintenance`, + prompt: `Write a ${scriptType === 'powershell' ? 'PowerShell' : 'CMD/Batch'} script that: ${prompt}`, + abortSignal, + }); + + if (!object) { + throw new Error('AI failed to generate a script. Please try again.'); + } + + return object; +} + +// ============================================================================= +// Service Report AI Summarizer +// ============================================================================= + +export interface AiReportSummary { + summary: string; + healthScore: number; +} + +/** + * Use AI to generate an executive summary and health score for a service report. + */ +export async function aiSummarizeReport( + report: ServiceReport, + definitions: ServiceDefinition[], + settings: AgentSettings, + abortSignal?: AbortSignal +): Promise { + if (!isAiConfigured(settings)) { + throw new Error('AI not configured. Set up a provider in Settings โ†’ AI Agent.'); + } + + const model = createProviderModel(settings); + const defMap = new Map(definitions.map((d) => [d.id, d])); + + // Build a structured report context for the AI + const totalServices = report.results.length; + const passed = report.results.filter((r) => r.success).length; + const failed = totalServices - passed; + const totalDuration = report.totalDurationMs + ? (report.totalDurationMs / 1000).toFixed(1) + : 'unknown'; + + const serviceDetails = report.results + .map((result) => { + const def = defMap.get(result.serviceId); + const serviceName = def?.name || result.serviceId; + const status = result.success ? 'PASSED' : 'FAILED'; + const findings = result.findings + .map((f) => ` [${f.severity.toUpperCase()}] ${f.title}: ${f.description}`) + .join('\n'); + const error = result.error ? ` Error: ${result.error}` : ''; + return `${serviceName} (${status}, ${(result.durationMs / 1000).toFixed(1)}s):\n${findings}${error}`; + }) + .join('\n\n'); + + const { object } = await generateObject({ + model, + schema: z.object({ + summary: z + .string() + .describe( + 'Executive summary of the service report (3-6 sentences). Highlight key findings, issues found, and overall system health. Be practical and actionable.' + ), + healthScore: z + .number() + .min(0) + .max(100) + .describe( + 'Overall system health score 0-100. 90-100=excellent, 70-89=good, 50-69=fair (some issues), 30-49=poor (significant issues), 0-29=critical' + ), + }), + system: `You are a computer repair technician's AI assistant. Analyze service report results and provide a brief executive summary with an overall health score. + +Rules: +- Be concise and practical โ€” this is for a technician, not a customer +- Highlight the most important findings (critical/error/warning severity) +- Mention what passed and what needs attention +- The health score should reflect the overall state: deduct points for errors, warnings, and failures +- If all services passed with no warnings, score should be 85-100 +- If there are warnings but no errors, score should be 60-85 +- If there are errors or failures, score should be lower accordingly +- Format the summary as plain text paragraphs, no markdown`, + prompt: `Service Report Summary: +- Services: ${totalServices} total, ${passed} passed, ${failed} failed +- Duration: ${totalDuration}s +- Parallel Mode: ${report.parallelMode ? 'Yes' : 'No'} + +Detailed Results: +${serviceDetails}`, + abortSignal, + }); + + if (!object) { + throw new Error('AI failed to generate a summary. Please try again.'); + } + + return object; +} diff --git a/src/lib/mcp-manager.ts b/src/lib/mcp-manager.ts new file mode 100644 index 0000000..f4f581c --- /dev/null +++ b/src/lib/mcp-manager.ts @@ -0,0 +1,196 @@ +/** + * MCP Client Manager + * + * Manages connections to external MCP servers, providing tool discovery + * and lifecycle management. Uses @ai-sdk/mcp for protocol support. + */ + +import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'; +import type { ToolSet } from 'ai'; +import type { MCPServerConfig } from '@/types/agent'; + +// ============================================================================= +// Types +// ============================================================================= + +interface ConnectedServer { + config: MCPServerConfig; + client: MCPClient; + tools: ToolSet; + error?: string; +} + +export interface MCPManagerState { + /** Currently connected servers */ + servers: ConnectedServer[]; + /** Total tool count across all connected servers */ + toolCount: number; + /** Whether a connection attempt is in progress */ + isConnecting: boolean; + /** Errors from failed connections */ + errors: Array<{ serverId: string; serverName: string; error: string }>; +} + +// ============================================================================= +// MCP Manager +// ============================================================================= + +/** + * Active MCP client connections - module-level singleton + */ +let activeConnections: ConnectedServer[] = []; +let connectionErrors: Array<{ serverId: string; serverName: string; error: string }> = []; + +/** + * Connect to all enabled MCP servers and retrieve their tools. + * Disconnects any previously connected servers first. + */ +export async function connectMCPServers( + configs: MCPServerConfig[] +): Promise<{ tools: ToolSet; state: MCPManagerState }> { + // Disconnect existing connections first + await disconnectAll(); + + const enabledConfigs = configs.filter(c => c.enabled && c.url); + if (enabledConfigs.length === 0) { + return { + tools: {}, + state: { + servers: [], + toolCount: 0, + isConnecting: false, + errors: [], + }, + }; + } + + const connections: ConnectedServer[] = []; + const errors: Array<{ serverId: string; serverName: string; error: string }> = []; + let mergedTools: ToolSet = {}; + + // Connect to each server individually (don't let one failure break all) + for (const config of enabledConfigs) { + try { + console.log(`[MCP] Connecting to "${config.name}" at ${config.url}...`); + + // Build headers + const headers: Record = {}; + if (config.apiKey) { + headers['Authorization'] = `Bearer ${config.apiKey}`; + } + if (config.headers) { + Object.assign(headers, config.headers); + } + + const client = await createMCPClient({ + transport: { + type: config.transportType, + url: config.url, + ...(Object.keys(headers).length > 0 ? { headers } : {}), + }, + }); + + const tools = await client.tools(); + const toolCount = Object.keys(tools).length; + console.log(`[MCP] Connected to "${config.name}" - ${toolCount} tools available`); + + connections.push({ config, client, tools: tools as unknown as ToolSet }); + + // Prefix tool names with server ID to avoid collisions + for (const [toolName, toolDef] of Object.entries(tools)) { + const prefixedName = `mcp_${config.id}_${toolName}`; + mergedTools[prefixedName] = toolDef as any; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[MCP] Failed to connect to "${config.name}":`, errorMsg); + errors.push({ + serverId: config.id, + serverName: config.name, + error: errorMsg, + }); + } + } + + activeConnections = connections; + connectionErrors = errors; + + const state: MCPManagerState = { + servers: connections, + toolCount: Object.keys(mergedTools).length, + isConnecting: false, + errors, + }; + + return { tools: mergedTools, state }; +} + +/** + * Disconnect all active MCP server connections + */ +export async function disconnectAll(): Promise { + const closePromises = activeConnections.map(async (conn) => { + try { + await conn.client.close(); + console.log(`[MCP] Disconnected from "${conn.config.name}"`); + } catch (error) { + console.warn(`[MCP] Error disconnecting from "${conn.config.name}":`, error); + } + }); + + await Promise.all(closePromises); + activeConnections = []; + connectionErrors = []; +} + +/** + * Get the current MCP manager state + */ +export function getMCPState(): MCPManagerState { + return { + servers: activeConnections, + toolCount: activeConnections.reduce((sum, c) => sum + Object.keys(c.tools).length, 0), + isConnecting: false, + errors: connectionErrors, + }; +} + +/** + * Get tools from all currently connected MCP servers + */ +export function getMCPTools(): ToolSet { + const mergedTools: ToolSet = {}; + for (const conn of activeConnections) { + for (const [toolName, toolDef] of Object.entries(conn.tools)) { + const prefixedName = `mcp_${conn.config.id}_${toolName}`; + mergedTools[prefixedName] = toolDef; + } + } + return mergedTools; +} + +/** + * Check if any MCP servers are connected + */ +export function hasActiveConnections(): boolean { + return activeConnections.length > 0; +} + +/** + * Get connected server info (for display) + */ +export function getConnectedServerInfo(): Array<{ + id: string; + name: string; + url: string; + toolCount: number; + toolNames: string[]; +}> { + return activeConnections.map(conn => ({ + id: conn.config.id, + name: conn.config.name, + url: conn.config.url, + toolCount: Object.keys(conn.tools).length, + toolNames: Object.keys(conn.tools), + })); +} diff --git a/src/lib/service-knowledge.ts b/src/lib/service-knowledge.ts new file mode 100644 index 0000000..cd6a4e9 --- /dev/null +++ b/src/lib/service-knowledge.ts @@ -0,0 +1,132 @@ +/** + * Service Knowledge Base + * + * Maps common user symptoms/complaints to recommended services. + * This is injected into the agent's system prompt so it can + * intelligently suggest service queues based on conversation context. + */ + +export interface ServiceRecommendation { + /** Symptom keywords/phrases that trigger this recommendation */ + symptoms: string[]; + /** Service IDs to recommend */ + services: string[]; + /** Explanation for why these services are recommended */ + reason: string; + /** Priority: higher = suggest first */ + priority: number; +} + +export const SERVICE_RECOMMENDATIONS: ServiceRecommendation[] = [ + { + symptoms: ['slow', 'sluggish', 'lag', 'takes forever', 'freezing', 'unresponsive', 'performance'], + services: ['disk-space', 'bleachbit', 'sfc', 'dism', 'drivecleanup', 'installed-software', 'driver-audit'], + reason: 'Performance issues often stem from disk space, corrupted system files, or outdated drivers', + priority: 10, + }, + { + symptoms: ['crash', 'bsod', 'blue screen', 'restart', 'stop error', 'bugcheck'], + services: ['chkdsk', 'sfc', 'dism', 'driver-audit', 'smartctl', 'battery-report'], + reason: 'Crashes can indicate disk errors, corrupted system files, driver issues, or hardware failure', + priority: 10, + }, + { + symptoms: ['virus', 'malware', 'infected', 'suspicious', 'popup', 'adware', 'ransomware', 'security'], + services: ['kvrt-scan', 'adwcleaner', 'stinger', 'bleachbit'], + reason: 'Security threats require multi-engine scanning and cleanup of cached malware artifacts', + priority: 10, + }, + { + symptoms: ['internet', 'wifi', 'network', 'connection', 'disconnect', 'no internet', 'dns', 'slow download'], + services: ['ping-test', 'speedtest', 'network-config'], + reason: 'Network issues need connectivity testing, speed measurement, and configuration review', + priority: 9, + }, + { + symptoms: ['battery', 'charge', 'draining', 'power', 'dies fast', 'not charging', 'battery life'], + services: ['battery-report', 'energy-report'], + reason: 'Battery concerns require health checks, usage reports, and energy efficiency analysis', + priority: 8, + }, + { + symptoms: ['disk', 'storage', 'full', 'space', 'hard drive', 'ssd', 'hdd'], + services: ['disk-space', 'smartctl', 'bleachbit', 'drivecleanup', 'chkdsk'], + reason: 'Storage issues need space analysis, drive health checks, and cleanup', + priority: 8, + }, + { + symptoms: ['update', 'windows update', 'patch', 'outdated', 'version'], + services: ['windows-update', 'driver-audit', 'sfc', 'dism'], + reason: 'Update issues often need Windows Update management and system file repair', + priority: 7, + }, + { + symptoms: ['driver', 'hardware', 'device', 'not working', 'not detected', 'missing driver'], + services: ['driver-audit', 'dism', 'sfc'], + reason: 'Hardware/driver issues require driver auditing and system integrity checks', + priority: 7, + }, + { + symptoms: ['startup', 'boot', 'long boot', 'slow startup', 'takes long to start'], + services: ['startup-optimize', 'disk-space', 'bleachbit', 'sfc', 'dism', 'installed-software'], + reason: 'Slow boot can be caused by unnecessary startup programs, disk issues, or corrupted system files', + priority: 7, + }, + { + symptoms: ['hot', 'overheating', 'temperature', 'fan', 'thermal', 'throttle'], + services: ['heavyload', 'battery-report', 'energy-report', 'furmark'], + reason: 'Thermal issues need stress testing to identify cooling problems', + priority: 6, + }, + { + symptoms: ['gpu', 'graphics', 'display', 'screen', 'artifact', 'render'], + services: ['furmark', 'driver-audit'], + reason: 'Graphics issues need GPU stress testing and driver verification', + priority: 6, + }, + { + symptoms: ['general', 'checkup', 'health check', 'full check', 'everything', 'diagnostic', 'assessment'], + services: ['ping-test', 'disk-space', 'sfc', 'dism', 'chkdsk', 'driver-audit', 'battery-report', 'smartctl', 'installed-software', 'network-config'], + reason: 'General health check covers core diagnostics across all subsystems', + priority: 5, + }, + { + symptoms: ['cleanup', 'clean', 'junk', 'temporary', 'temp files', 'clear'], + services: ['bleachbit', 'drivecleanup', 'disk-space'], + reason: 'Cleanup tasks to free space and remove temporary/junk files', + priority: 6, + }, + { + symptoms: ['upgrade', 'windows 11', 'compatible', 'compatibility', 'can i run'], + services: ['whynotwin11', 'driver-audit', 'disk-space'], + reason: 'Compatibility checking for Windows 11 upgrade eligibility', + priority: 5, + }, + { + symptoms: ['benchmark', 'test', 'score', 'rating', 'speed test'], + services: ['winsat', 'speedtest', 'iperf', 'heavyload', 'furmark'], + reason: 'Benchmarking tools to measure system performance', + priority: 5, + }, +]; + +/** + * Generate the service knowledge section for the system prompt. + * Returns a formatted string mapping symptoms to service recommendations. + */ +export function getServiceKnowledgePrompt(): string { + const lines = [ + 'SERVICE RECOMMENDATION KNOWLEDGE:', + 'When the user describes a problem, suggest services based on these mappings:', + '', + ]; + + for (const rec of SERVICE_RECOMMENDATIONS.sort((a, b) => b.priority - a.priority)) { + lines.push(`โ€ข Symptoms: ${rec.symptoms.join(', ')}`); + lines.push(` Services: ${rec.services.join(', ')}`); + lines.push(` Reason: ${rec.reason}`); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/src/main.tsx b/src/main.tsx index 85f30fd..a98b38b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,16 @@ import ReactDOM from "react-dom/client"; import App from "./App"; import "./styles/globals.css"; +// Global error handlers โ€” catch errors outside React's render cycle +// (async handlers, timers, Tauri event listeners, etc.) +window.onerror = (message, source, lineno, colno, error) => { + console.error('[Global] Uncaught error:', { message, source, lineno, colno, error }); +}; + +window.addEventListener('unhandledrejection', (event) => { + console.error('[Global] Unhandled promise rejection:', event.reason); +}); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx new file mode 100644 index 0000000..8e47075 --- /dev/null +++ b/src/pages/AgentPage.tsx @@ -0,0 +1,1307 @@ +/** + * Agent Page + * + * Main interface for the ServiceAgent AI system with chat UI, + * instrument sidebar, and command approval flow. + */ + +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { streamChat } from '@/lib/agent-chat'; +import { getEnabledTools, isHITLTool, shouldRequireApproval } from '@/lib/agent-tools'; +import { validateToolCall, mapToolToActivityType, extractActivityDetails } from '@/lib/agent-activity-utils'; +import { connectMCPServers, disconnectAll as disconnectMCPServers, getMCPTools, type MCPManagerState } from '@/lib/mcp-manager'; +import { AgentLoopQueue, type LoopOptions } from '@/lib/agent-loop-queue'; +import { AgentHeartbeat, type HeartbeatStatus } from '@/lib/agent-heartbeat'; +import { useConversations, type Message } from '@/hooks/useConversations'; +import { useServiceSupervision } from '@/hooks/useServiceSupervision'; +import { CoreMessage, generateId, ToolCallPart } from 'ai'; +import { ChatMessage, type MessagePart } from '@/components/agent/ChatMessage'; +import { AgentRightSidebar } from '@/components/agent/AgentRightSidebar'; +import { ConversationSelector } from '@/components/agent/ConversationSelector'; +import { ServiceRunMonitor } from '@/components/agent/ServiceRunMonitor'; +import type { ApprovalMode, ProviderApiKeys } from '@/types/agent'; +import type { AgentActivity, ActivityStatus } from '@/types/agent-activity'; +import type { FileAttachment } from '@/types/file-attachment'; +import type { ServiceReport, ServiceRunState as ServiceRunStateType } from '@/types/service'; +import { + Bot, + Send, + Loader2, + Sparkles, + Trash2, + Shield, + ShieldAlert, + ShieldOff, + Square, + Zap, + Menu, + PanelRightClose, + PanelRight, + Paperclip, + X, + AlertTriangle, + RotateCcw, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useSettings } from '@/components/settings-context'; +import { useAnimation, motion, AnimatePresence } from '@/components/animation-context'; + +// ============================================================================= +// Components +// ============================================================================= + +/** + * Approval mode indicator + */ +function ApprovalModeIndicator({ mode }: { mode: ApprovalMode }) { + const config = { + always: { + icon: Shield, + label: 'Safe Mode', + className: 'text-green-500 bg-green-500/10 border-green-500/30', + }, + whitelist: { + icon: ShieldAlert, + label: 'Whitelist Mode', + className: 'text-yellow-500 bg-yellow-500/10 border-yellow-500/30', + }, + yolo: { + icon: ShieldOff, + label: 'YOLO Mode', + className: 'text-red-500 bg-red-500/10 border-red-500/30', + }, + }; + + const { icon: Icon, label, className } = config[mode]; + + return ( + + + {label} + + ); +} + +/** + * Setup prompt when no API key is configured + */ +function SetupPrompt() { + const navigateToSettings = () => { + window.dispatchEvent(new CustomEvent('navigate-tab', { detail: 'settings' })); + }; + + return ( + + +
    + +
    + Configure Agent + + Set up your AI provider and API key to start using the agent. + +
    + + + +
    + ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function AgentPage() { + const { settings } = useSettings(); + const { fadeInUp } = useAnimation(); + + // State + const messagesRef = useRef([]); + const [messages, setMessagesRaw] = useState([]); + // Wrapper that keeps messagesRef in sync synchronously to avoid stale reads + // when the agent loop recurses before React re-renders. + const setMessages = useCallback((update: Message[] | ((prev: Message[]) => Message[])) => { + setMessagesRaw(prev => { + const next = typeof update === 'function' ? update(prev) : update; + messagesRef.current = next; + return next; + }); + }, []); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showRightSidebar, setShowRightSidebar] = useState(true); + const [pendingAttachments, setPendingAttachments] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const mcpServersKeyRef = useRef(''); + + const [mcpState, setMcpState] = useState({ + servers: [], + toolCount: 0, + isConnecting: false, + errors: [], + }); + + const agentHistoryRef = useRef([]); + const messagesEndRef = useRef(null); + const abortControllerRef = useRef(null); + const pendingActivityUpdatesRef = useRef(new Map>()); + + // Agent loop queue โ€” ensures only one loop runs at a time + const loopQueueRef = useRef(null!); + if (!loopQueueRef.current) { + // Placeholder fn, updated below once runAgentLoop is defined + loopQueueRef.current = new AgentLoopQueue(async () => {}); + } + + // Agent heartbeat โ€” detects stalled loops + const heartbeatRef = useRef(null!); + const [heartbeatStatus, setHeartbeatStatus] = useState('idle'); + if (!heartbeatRef.current) { + heartbeatRef.current = new AgentHeartbeat(); + heartbeatRef.current.onStatusChange(setHeartbeatStatus); + } + + // Multi-HITL resolution tracking + const pendingHITLCallsRef = useRef>>(new Map()); + const hitlToolResultsRef = useRef>(new Map()); + + const agentSettings = settings.agent; + const currentProvider = agentSettings?.provider || 'openai'; + const currentApiKey = agentSettings?.apiKeys?.[currentProvider as keyof ProviderApiKeys] || ''; + const isConfigured = currentApiKey.length > 0; + + // Conversation management hook + const { + currentConversationId, + setConversationTitle, + saveConversation, + loadConversation, + startNewConversation, + isFirstMessageRef, + } = useConversations({ isConfigured, setMessages, agentHistoryRef }); + + // Service supervision hook + const { activeServiceRun, setActiveServiceRun } = useServiceSupervision({ + agentHistoryRef, + loopQueueRef, + }); + + const toolSummary = useMemo(() => { + const enabledTools = getEnabledTools({ + searchProvider: settings.agent?.searchProvider || 'none', + }); + const enabled = new Set(Object.keys(enabledTools)); + return [ + { id: 'execute_command', name: 'Commands', desc: 'Execute PowerShell', requiresApproval: true }, + { id: 'read_file', name: 'Read File', desc: 'Open file contents' }, + { id: 'edit_file', name: 'Edit File', desc: 'Replace text in files', requiresApproval: true }, + { id: 'write_file', name: 'Write File', desc: 'Create or overwrite files', requiresApproval: true }, + { id: 'generate_file', name: 'Generate File', desc: 'Create downloadable files', requiresApproval: true }, + { id: 'move_file', name: 'Move File', desc: 'Move or rename files', requiresApproval: true }, + { id: 'copy_file', name: 'Copy File', desc: 'Copy files', requiresApproval: true }, + { id: 'list_dir', name: 'List Directory', desc: 'List folder contents' }, + { id: 'grep', name: 'Grep', desc: 'Search text across files' }, + { id: 'glob', name: 'Glob', desc: 'Find files by pattern' }, + { id: 'list_programs', name: 'Programs', desc: 'List portable tools' }, + { id: 'list_instruments', name: 'Instruments', desc: 'List available scripts' }, + { id: 'run_instrument', name: 'Run Instrument', desc: 'Execute a script' }, + { id: 'search_web', name: 'Web Search', desc: 'Search the internet' }, + { id: 'get_system_info', name: 'System Info', desc: 'Hardware & OS details' }, + ].map(tool => ({ ...tool, enabled: enabled.has(tool.id) })); + }, [settings.agent.searchProvider]); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // messagesRef is kept in sync synchronously via the setMessages wrapper above + + // Connect to MCP servers when settings change (stabilized to prevent reconnection loop) + useEffect(() => { + const mcpServers = agentSettings?.mcpServers || []; + const key = JSON.stringify(mcpServers.map(s => ({ id: s.id, url: s.url, enabled: s.enabled }))); + if (key === mcpServersKeyRef.current) return; // No actual change + mcpServersKeyRef.current = key; + + const enabledServers = mcpServers.filter(s => s.enabled && s.url); + + if (enabledServers.length > 0) { + setMcpState(prev => ({ ...prev, isConnecting: true })); + connectMCPServers(mcpServers).then(({ state }) => { + setMcpState(state); + }).catch(err => { + console.error('[MCP] Connection error:', err); + setMcpState(prev => ({ ...prev, isConnecting: false })); + }); + } else { + disconnectMCPServers().then(() => { + setMcpState({ servers: [], toolCount: 0, isConnecting: false, errors: [] }); + }); + } + + return () => { + disconnectMCPServers(); + }; + }, [agentSettings?.mcpServers]); + + // validateToolCall, mapToolToActivityType, extractActivityDetails imported from @/lib/agent-activity-utils + + const findMessageIdForActivity = (activityId: string) => { + for (const msg of messagesRef.current) { + if (!msg.parts) continue; + const hasActivity = msg.parts.some(part => part.type === 'tool' && part.activity?.id === activityId); + if (hasActivity) return msg.id; + } + return null; + }; + + // Helper: update an activity within a message's parts array + const updateActivityInParts = (msgId: string | null, activityId: string, updates: Partial) => { + let found = false; + setMessages(prev => prev.map(msg => { + if (msgId && msg.id !== msgId) return msg; + if (!msg.parts) return msg; + const partIdx = msg.parts.findIndex(p => p.type === 'tool' && p.activity?.id === activityId); + if (partIdx === -1) return msg; + found = true; + const newParts = [...msg.parts]; + newParts[partIdx] = { ...newParts[partIdx], activity: { ...newParts[partIdx].activity!, ...updates } as AgentActivity }; + return { ...msg, parts: newParts }; + })); + + if (!found) { + const existing = pendingActivityUpdatesRef.current.get(activityId) || {}; + pendingActivityUpdatesRef.current.set(activityId, { ...existing, ...updates }); + } + }; + + /** + * Execute a single HITL tool call and return the CoreMessage result. + * Shared by YOLO auto-execute and manual approval paths. + */ + const executeHITLTool = async ( + toolCallId: string, + toolName: string, + args: Record, + reason?: string, + ): Promise => { + updateActivityInParts(null, toolCallId, { status: 'running' as ActivityStatus }); + + let result: string; + let isError = false; + + try { + const validation = validateToolCall(toolName, args); + if (!validation.valid) { + result = validation.error || 'Invalid tool call - missing required arguments'; + isError = true; + } else { + switch (toolName) { + case 'execute_command': { + const command = String(args.command || ''); + const res = await invoke<{ output?: string; error?: string }>('execute_agent_command', { + command, + reason: String(args.reason || reason || 'Agent approved'), + }); + result = res.output || res.error || 'Command executed successfully.'; + isError = !!res.error; + break; + } + case 'write_file': { + await invoke('agent_write_file', { + path: String(args.path || ''), + content: String(args.content || ''), + }); + result = `Successfully wrote to ${args.path}`; + break; + } + case 'edit_file': { + const res = await invoke<{ status: string; replacements: number; message?: string }>('agent_edit_file', { + path: String(args.path || ''), + old_string: String(args.oldString || ''), + new_string: String(args.newString || ''), + all: Boolean(args.all), + }); + result = res.message || `Edited ${args.path} (${res.replacements} replacements)`; + isError = res.status !== 'success'; + break; + } + case 'generate_file': { + const attachment = await invoke('generate_agent_file', { + filename: String(args.filename || 'generated.txt'), + content: String(args.content || ''), + description: String(args.description || ''), + mime_type: typeof (args as any).mime_type === 'string' ? (args as any).mime_type : undefined, + tool_call_id: toolCallId, + approved: true, + }); + result = `Generated ${attachment.originalName}`; + updateActivityInParts(null, toolCallId, { + filename: attachment.originalName, + path: attachment.storedPath, + size: attachment.size, + }); + break; + } + case 'move_file': { + await invoke('agent_move_file', { + src: String(args.src || ''), + dest: String(args.dest || ''), + }); + result = `Moved ${args.src} to ${args.dest}`; + break; + } + case 'copy_file': { + await invoke('agent_copy_file', { + src: String(args.src || ''), + dest: String(args.dest || ''), + }); + result = `Copied ${args.src} to ${args.dest}`; + break; + } + case 'run_service_queue': { + const queue = (args.queue as Array<{ service_id: string; enabled: boolean; order: number; options?: Record }>)?.map(q => ({ + serviceId: q.service_id, + enabled: q.enabled, + order: q.order, + options: q.options || {}, + })) || []; + const reportPromise = invoke('run_services', { + queue, + technician_name: args.technician_name ? String(args.technician_name) : null, + customer_name: args.customer_name ? String(args.customer_name) : null, + }); + await new Promise(r => setTimeout(r, 500)); + const state = await invoke('get_service_run_state'); + const reportId = state.currentReport?.id || 'unknown'; + setActiveServiceRun({ + reportId, + startedAt: new Date().toISOString(), + lastResultCount: 0, + assistantMsgId: findMessageIdForActivity(toolCallId) || null, + }); + reportPromise.catch(err => console.error('[Agent] Service run error:', err)); + result = JSON.stringify({ status: 'started', report_id: reportId, message: `Service run started with ${queue.filter(q => q.enabled).length} services` }); + break; + } + case 'pause_service': { + await invoke('pause_service_run'); + result = JSON.stringify({ status: 'success', message: 'Service run paused' }); + break; + } + case 'resume_service': { + await invoke('resume_service_run'); + result = JSON.stringify({ status: 'success', message: 'Service run resumed' }); + break; + } + case 'cancel_service': { + await invoke('cancel_service_run'); + setActiveServiceRun(null); + result = JSON.stringify({ status: 'success', message: 'Service run cancelled' }); + break; + } + default: + result = `Unknown HITL tool: ${toolName}`; + isError = true; + } + } + } catch (error) { + result = String(error); + isError = true; + } + + updateActivityInParts(null, toolCallId, { + status: isError ? 'error' as ActivityStatus : 'success' as ActivityStatus, + output: result, + error: isError ? result : undefined, + }); + + return { + role: 'tool', + content: [{ + type: 'tool-result', + toolCallId, + toolName, + output: isError + ? { type: 'error-text' as const, value: result } + : { type: 'text' as const, value: result }, + }], + }; + }; + + // Core agentic loop - streams response and handles tool calls recursively. + // Creates ONE assistant message per turn with interleaved parts (text โ†” tool). + const runAgentLoop = async ( + currentHistory: CoreMessage[], + options?: LoopOptions, + ) => { + setIsLoading(true); + heartbeatRef.current.start(); + abortControllerRef.current = new AbortController(); + const turnsRemaining = options?.turnsRemaining ?? 50; + + const reuseMessageId = options?.reuseMessageId ?? null; + let assistantMsgId = reuseMessageId || generateId(); + let initialParts: MessagePart[] = []; + let initialContent = ''; + + if (reuseMessageId) { + // Prefer parts passed directly from the previous iteration to avoid + // a race with React's batched state updates (messagesRef may be stale). + if (options?._currentParts) { + initialParts = [...options._currentParts] as MessagePart[]; + initialContent = options._currentContent || ''; + } else { + const existing = messagesRef.current.find(m => m.id === reuseMessageId); + if (existing) { + initialParts = existing.parts ? [...existing.parts] : []; + initialContent = existing.content || ''; + } else { + assistantMsgId = generateId(); + } + } + } + + const createdNewMessage = !reuseMessageId || assistantMsgId !== reuseMessageId; + if (createdNewMessage) { + // Create a single assistant message for this turn + setMessages(prev => [...prev, { + id: assistantMsgId, + role: 'assistant', + content: '', + createdAt: new Date().toISOString(), + parts: [], + }]); + } + + try { + const localTools = getEnabledTools({ + searchProvider: settings.agent?.searchProvider || 'none', + }); + + // Merge local tools with MCP tools from connected servers + const mcpTools = getMCPTools(); + const tools = { ...localTools, ...mcpTools }; + + const result = await streamChat({ + messages: currentHistory, + settings: agentSettings!, + tools, + abortSignal: abortControllerRef.current.signal, + maxSteps: activeServiceRun ? 30 : 10, + }); + + // Track accumulated state for this turn โ€” all in ONE message + const parts: MessagePart[] = [...initialParts]; + let fullContent = initialContent; + let historyTextContent = ''; + let currentTextContent = ''; + let currentTextPartIdx = -1; + let finalToolCalls: ToolCallPart[] = []; + const toolResultMessages: CoreMessage[] = []; + const toolCallValidation = new Map(); + + const updateMsg = () => { + setMessages(prev => prev.map(m => + m.id === assistantMsgId ? { ...m, content: fullContent, parts: [...parts] } : m + )); + }; + + // Process the stream + for await (const part of result.fullStream) { + heartbeatRef.current.ping(); + + if (abortControllerRef.current?.signal.aborted) { + break; + } + + if (part.type === 'text-delta') { + // If no current text part or last part was a tool, start new text part + if (currentTextPartIdx === -1 || parts[currentTextPartIdx]?.type !== 'text') { + parts.push({ type: 'text', content: '' }); + currentTextPartIdx = parts.length - 1; + currentTextContent = ''; + } + currentTextContent += part.text; + fullContent += part.text; + historyTextContent += part.text; + parts[currentTextPartIdx] = { type: 'text', content: currentTextContent }; + updateMsg(); + } + else if (part.type === 'tool-call') { + // Track tool call + finalToolCalls.push(part); + + const args = ((part as any).args ?? (part as any).input ?? {}) as Record; + + // Validate tool call args + const validation = validateToolCall(part.toolName, args); + if (!validation.valid) { + console.warn('[Agent] Malformed tool call:', part.toolName, 'Args:', args, 'Error:', validation.error); + } + toolCallValidation.set(part.toolCallId, { valid: validation.valid, error: validation.error }); + + const activityType = mapToolToActivityType(part.toolName); + const activityDetails = extractActivityDetails(part.toolName, args); + + // Check if this tool requires approval based on approval mode + const approvalMode = agentSettings?.approvalMode || 'always'; + const requiresApproval = shouldRequireApproval(part.toolName, approvalMode); + + const newActivity = { + id: part.toolCallId, + timestamp: new Date().toISOString(), + type: activityType, + status: validation.valid ? (requiresApproval ? 'pending_approval' : 'running') : 'error', + error: validation.valid ? undefined : validation.error, + ...activityDetails + } as AgentActivity; + + const pendingUpdates = pendingActivityUpdatesRef.current.get(part.toolCallId); + const resolvedActivity = pendingUpdates + ? ({ ...newActivity, ...pendingUpdates } as AgentActivity) + : newActivity; + if (pendingUpdates) { + pendingActivityUpdatesRef.current.delete(part.toolCallId); + } + + // Add tool part and reset text tracking so next text starts a new part + parts.push({ type: 'tool', activity: resolvedActivity }); + currentTextPartIdx = -1; + updateMsg(); + } + else if (part.type === 'tool-result') { + // For server-side tools that auto-execute + const resultData = (part as any).result ?? (part as any).output; + let output: string; + let isError = false; + + if (typeof resultData === 'string') { + output = resultData; + } else if (resultData && typeof resultData === 'object') { + const resultObj = resultData as { status?: string; output?: string; error?: string }; + isError = resultObj.status === 'error'; + output = resultObj.output || resultObj.error || JSON.stringify(resultData); + } else { + output = JSON.stringify(resultData); + } + + // Find and update the matching tool part + const toolPartIdx = parts.findIndex(p => p.type === 'tool' && p.activity?.id === part.toolCallId); + if (toolPartIdx !== -1) { + parts[toolPartIdx] = { + ...parts[toolPartIdx], + activity: { + ...parts[toolPartIdx].activity!, + status: isError ? 'error' : 'success', + output, + error: isError ? output : undefined, + } as AgentActivity, + }; + updateMsg(); + } + + // Record tool result in history so the model can continue if needed + const resultValue = typeof resultData === 'undefined' ? output : resultData; + toolResultMessages.push({ + role: 'tool', + content: [{ + type: 'tool-result', + toolCallId: part.toolCallId, + toolName: part.toolName, + output: isError + ? { type: 'error-text' as const, value: resultValue } + : { type: 'text' as const, value: resultValue }, + }] + }); + } + } + + // Stream finished. Update history with the full assistant message. + const assistantMessage: CoreMessage = { + role: 'assistant', + content: [ + ...(historyTextContent ? [{ type: 'text' as const, text: historyTextContent }] : []), + ...finalToolCalls.map(tc => ({ + type: 'tool-call' as const, + toolCallId: tc.toolCallId, + toolName: tc.toolName, + input: (tc as any).args ?? (tc as any).input, + })) + ] + }; + + const baseHistory = [...currentHistory, assistantMessage, ...toolResultMessages]; + agentHistoryRef.current = baseHistory; + + // Check for HITL tool calls that need handling + const hitlCalls = finalToolCalls.filter(tc => isHITLTool(tc.toolName)); + const approvalMode = agentSettings?.approvalMode || 'always'; + + if (hitlCalls.length === 0) { + if (turnsRemaining > 0 && toolResultMessages.length > 0 && !historyTextContent.trim()) { + // Model used tools but produced no text โ€” auto-continue so it can + // keep working. Pass parts directly to avoid React state race. + await runAgentLoop(baseHistory, { + turnsRemaining: turnsRemaining - 1, + reuseMessageId: assistantMsgId, + _currentParts: parts, + _currentContent: fullContent, + }); + } else { + heartbeatRef.current.stop(); + setIsLoading(false); + } + } else if (approvalMode === 'yolo') { + // YOLO mode: execute HITL tools sequentially, then continue once + const toolResults: CoreMessage[] = []; + for (const tc of hitlCalls) { + const validation = toolCallValidation.get(tc.toolCallId); + if (validation && !validation.valid) { + toolResults.push({ + role: 'tool', + content: [{ + type: 'tool-result', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + output: { + type: 'error-text' as const, + value: validation.error || 'Invalid tool call - missing required arguments', + }, + }] + }); + continue; + } + + const args = (tc as any).args ?? (tc as any).input ?? {}; + const resultMsg = await executeHITLTool(tc.toolCallId, tc.toolName, args, 'YOLO mode - auto-approved'); + toolResults.push(resultMsg); + } + + const newHistory = [...baseHistory, ...toolResults]; + agentHistoryRef.current = newHistory; + await runAgentLoop(newHistory, { + reuseMessageId: assistantMsgId, + turnsRemaining: turnsRemaining - 1, + _currentParts: parts, + _currentContent: fullContent, + }); + } else { + // Multi-HITL: track all pending HITL calls so we only resume after all are resolved + const hitlCallIds = new Set(hitlCalls.map(tc => tc.toolCallId)); + pendingHITLCallsRef.current.set(assistantMsgId, hitlCallIds); + hitlToolResultsRef.current.set(assistantMsgId, []); + + heartbeatRef.current.stop(); + setIsLoading(false); // Paused for manual HITL approval + } + + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Remove the empty assistant message on abort + if (createdNewMessage) { + setMessages(prev => prev.filter(msg => msg.id !== assistantMsgId)); + } + heartbeatRef.current.stop(); + setIsLoading(false); + return; + } + + // Handle AI_NoOutputGeneratedError gracefully (model returned empty) + if (error instanceof Error && error.message?.includes('No output generated')) { + console.warn('[Agent] No output generated by model โ€” ending turn.'); + // Remove the empty assistant message + if (createdNewMessage) { + setMessages(prev => prev.filter(msg => msg.id !== assistantMsgId || (msg.parts && msg.parts.length > 0))); + } + heartbeatRef.current.stop(); + setIsLoading(false); + return; + } + + console.error('Agent loop error:', error); + setMessages(prev => prev.map(m => + m.id === assistantMsgId + ? { ...m, content: m.content + `\n\n*[Error: ${error}]*`, parts: [...(m.parts || []), { type: 'text', content: `\n\n*[Error: ${error}]*` }] } + : m + )); + heartbeatRef.current.stop(); + setIsLoading(false); + } + }; + + // Stop the agentic loop + const stopAgentLoop = useCallback(() => { + abortControllerRef.current?.abort(); + heartbeatRef.current.stop(); + loopQueueRef.current.clear(); + setIsLoading(false); + }, []); + + // Keep the queue's run function reference up to date + loopQueueRef.current.setRunFn(runAgentLoop); + + const handleActivityApprove = async (activityId: string) => { + // 1. Find the pending tool call in the last assistant message (not necessarily + // the last message โ€” auto-execute tool results may follow it in history). + const history = agentHistoryRef.current; + const lastAssistantMsg = [...history].reverse().find(m => m.role === 'assistant'); + if (!lastAssistantMsg) return; + + // Handle both array content and single content + const contentArray = Array.isArray(lastAssistantMsg.content) ? lastAssistantMsg.content : []; + const toolCall = contentArray.find( + (c): c is ToolCallPart => c.type === 'tool-call' && c.toolCallId === activityId + ); + + if (!toolCall) { + console.error('Tool call not found for activity:', activityId); + return; + } + + // 2. Execute the tool (shared helper handles UI status updates) + setIsLoading(true); + const args = ((toolCall as any).args ?? (toolCall as any).input ?? {}) as Record; + const toolResultMsg = await executeHITLTool(activityId, toolCall.toolName, args, 'User approved'); + + // 3. Multi-HITL: accumulate result, only resume when all calls resolved + const msgId = findMessageIdForActivity(activityId); + const pendingSet = [...pendingHITLCallsRef.current.entries()].find(([, set]) => set.has(activityId)); + + if (pendingSet) { + const [pendingMsgId, callIds] = pendingSet; + callIds.delete(activityId); + const accumulated = hitlToolResultsRef.current.get(pendingMsgId) || []; + accumulated.push(toolResultMsg); + hitlToolResultsRef.current.set(pendingMsgId, accumulated); + + if (callIds.size === 0) { + // All HITL calls resolved โ€” resume with ALL accumulated results + pendingHITLCallsRef.current.delete(pendingMsgId); + const allResults = hitlToolResultsRef.current.get(pendingMsgId) || []; + hitlToolResultsRef.current.delete(pendingMsgId); + + const newHistory = [...history, ...allResults]; + agentHistoryRef.current = newHistory; + await runAgentLoop(newHistory, { reuseMessageId: msgId }); + } + // else: still waiting for other HITL calls, don't resume yet + } else { + // No multi-HITL tracking (single call) โ€” resume immediately + const newHistory = [...history, toolResultMsg]; + agentHistoryRef.current = newHistory; + await runAgentLoop(newHistory, { reuseMessageId: msgId }); + } + }; + + const handleActivityReject = async (activityId: string) => { + // 1. Find tool call in the last assistant message + const history = agentHistoryRef.current; + const lastAssistantMsg = [...history].reverse().find(m => m.role === 'assistant'); + if (!lastAssistantMsg) return; + + const contentArray = Array.isArray(lastAssistantMsg.content) ? lastAssistantMsg.content : []; + const toolCall = contentArray.find( + (c): c is ToolCallPart => c.type === 'tool-call' && c.toolCallId === activityId + ); + + if (!toolCall) { + console.error('Tool call not found for activity:', activityId); + return; + } + + const rejectionMessage = 'User denied this action.'; + + // 2. Update UI + updateActivityInParts(null, activityId, { + status: 'error' as ActivityStatus, + output: rejectionMessage, + error: rejectionMessage, + }); + + // 3. Build rejection tool result + const toolResultMsg: CoreMessage = { + role: 'tool', + content: [{ + type: 'tool-result', + toolCallId: activityId, + toolName: toolCall.toolName, + output: { type: 'error-text' as const, value: rejectionMessage }, + }] + }; + + // 4. Multi-HITL: accumulate result, only resume when all calls resolved + const msgId = findMessageIdForActivity(activityId); + const pendingSet = [...pendingHITLCallsRef.current.entries()].find(([, set]) => set.has(activityId)); + + if (pendingSet) { + const [pendingMsgId, callIds] = pendingSet; + callIds.delete(activityId); + const accumulated = hitlToolResultsRef.current.get(pendingMsgId) || []; + accumulated.push(toolResultMsg); + hitlToolResultsRef.current.set(pendingMsgId, accumulated); + + if (callIds.size === 0) { + // All HITL calls resolved โ€” resume with ALL accumulated results + pendingHITLCallsRef.current.delete(pendingMsgId); + const allResults = hitlToolResultsRef.current.get(pendingMsgId) || []; + hitlToolResultsRef.current.delete(pendingMsgId); + + const newHistory = [...history, ...allResults]; + agentHistoryRef.current = newHistory; + setIsLoading(true); + await runAgentLoop(newHistory, { reuseMessageId: msgId }); + } + // else: still waiting for other HITL calls + } else { + // No multi-HITL tracking โ€” resume immediately + const newHistory = [...history, toolResultMsg]; + agentHistoryRef.current = newHistory; + setIsLoading(true); + await runAgentLoop(newHistory, { reuseMessageId: msgId }); + } + }; + + + const executeMessage = async (text: string, attachments?: FileAttachment[]) => { + if ((!text.trim() && !attachments?.length) || isLoading || !agentSettings) return; + + // Build content with file context + let messageContent = text; + if (attachments && attachments.length > 0) { + const fileContext = attachments.map(att => { + let context = `[File: ${att.originalName} (${att.category}, ${att.size} bytes)]`; + if (att.content) { + context += `\nContent:\n${att.content.substring(0, 2000)}${att.content.length > 2000 ? '\n... (truncated)' : ''}`; + } + return context; + }).join('\n\n'); + messageContent = text + '\n\n' + fileContext; + } + + // Add user message to UI + const newMessage: Message = { + id: generateId(), + role: 'user', + content: text, + createdAt: new Date().toISOString(), + attachments: attachments, + }; + setMessages(prev => [...prev, newMessage]); + + // Update history + const userMsg: CoreMessage = { role: 'user', content: messageContent }; + const newHistory = [...agentHistoryRef.current, userMsg]; + agentHistoryRef.current = newHistory; + + // Update title on first message + if (isFirstMessageRef.current && currentConversationId) { + isFirstMessageRef.current = false; + const title = text.length > 50 ? text.substring(0, 50) + '...' : text; + setConversationTitle(title); + invoke('update_conversation_title', { + conversationId: currentConversationId, + title, + }).catch(err => console.error('Failed to update title:', err)); + } + + // Start loop + await runAgentLoop(newHistory); + + // Auto-save after loop completes + const updatedMessages = [...messages, newMessage]; + saveConversation(updatedMessages, agentHistoryRef.current); + }; + + const handleSendMessage = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!input.trim() && pendingAttachments.length === 0) return; + const text = input.trim() || (pendingAttachments.length > 0 ? 'Please analyze these files:' : ''); + const attachments = [...pendingAttachments]; + setInput(''); + setPendingAttachments([]); + await executeMessage(text, attachments); + }; + + const handleRunInstrument = (name: string) => { + // We inject a user message to run it + const text = `Please run the instrument: ${name}`; + executeMessage(text); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(e); + } + }; + + // File upload handlers + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + setIsUploading(true); + const newAttachments: FileAttachment[] = []; + + for (const file of files) { + try { + // Read file as base64 + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); // Remove data URL prefix + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + + // Save to backend + const attachment = await invoke('save_uploaded_file', { + file_name: file.name, + mime_type: file.type || 'application/octet-stream', + size: file.size, + content_base64: base64, + }); + + newAttachments.push(attachment); + } catch (error) { + console.error('Failed to upload file:', error); + } + } + + setPendingAttachments(prev => [...prev, ...newAttachments]); + setIsUploading(false); + + // Reset input + e.target.value = ''; + }; + + const removePendingAttachment = (id: string) => { + setPendingAttachments(prev => prev.filter(att => att.id !== id)); + }; + + const clearChat = async () => { + abortControllerRef.current?.abort(); + heartbeatRef.current.stop(); + loopQueueRef.current.clear(); + pendingHITLCallsRef.current.clear(); + hitlToolResultsRef.current.clear(); + setIsLoading(false); + setMessages([]); + agentHistoryRef.current = []; + isFirstMessageRef.current = true; + try { + await invoke('clear_pending_commands'); + // Start a fresh conversation + await startNewConversation(); + } catch (err) { + console.error('Failed to clear chat:', err); + } + }; + + if (!isConfigured) { + return ( + + + + ); + } + + return ( + + {/* Header */} +
    +
    + +
    + +
    +
    +

    + ServiceAgent + +

    +

    + {agentSettings?.model || 'gpt-4o-mini'} +

    +
    +
    + +
    +
    + +
    +
    + +
    + {heartbeatStatus === 'stalled' && ( + + + Stalled + + )} + {heartbeatStatus === 'stalled' && ( + + )} + {isLoading && heartbeatStatus !== 'stalled' && ( + + + Thinking... + + )} + {isLoading && ( + + )} + +
    +
    + + {/* Main Layout */} +
    + {/* Center Chat Area */} +
    +
    +
    + {messages.length === 0 ? ( +
    +
    +
    +
    +
    + +
    +
    +
    +

    What can I help with?

    +

    + I can run commands, manage files, search the web, and help diagnose system issues. +

    +
    +
    + {[ + { icon: '๐Ÿ”', text: 'Run a quick system diagnostic' }, + { icon: '๐Ÿ“‚', text: 'List files in the programs folder' }, + { icon: 'โšก', text: 'Check disk health with SMART data' }, + { icon: '๐Ÿงน', text: 'Help me clean up temp files' }, + ].map((suggestion) => ( + + ))} +
    +
    +
    + ) : ( + + {messages.map((msg, index) => ( + + + + ))} + + )} + {/* Service Run Monitor */} + {activeServiceRun && ( + { + try { await invoke('pause_service_run'); } catch (e) { console.error(e); } + }} + onResume={async () => { + try { await invoke('resume_service_run'); } catch (e) { console.error(e); } + }} + onCancel={async () => { + try { + await invoke('cancel_service_run'); + setActiveServiceRun(null); + } catch (e) { console.error(e); } + }} + /> + )} + {/* Invisible element to scroll to */} +
    +
    +
    + + {/* Input Area */} +
    +
    +
    + {/* Pending Attachments Preview */} + {pendingAttachments.length > 0 && ( +
    + {pendingAttachments.map(att => ( +
    + + {att.originalName} + +
    + ))} +
    + )} + +
    +