first commit

This commit is contained in:
David Perov 2026-03-25 12:10:40 +03:00
commit 2bfcd012d2
122 changed files with 13030 additions and 0 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
target-dir = "/Users/trixter/.cargo-target/talk-flow"

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
# TalkFlow environment variables
# Copy this file to .env and add your API keys

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
._*
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

163
AGENTS.md Normal file
View file

@ -0,0 +1,163 @@
# TalkFlow - AGENTS.md
TalkFlow is a macOS voice-to-text application built with Tauri v2 (Rust backend) and React (TypeScript frontend).
## Project Structure
```
talk-flow/
├── src/ # Frontend (React/TypeScript)
│ ├── windows/
│ │ ├── widget/ # Small floating widget window
│ │ └── settings/ # Settings window with tabs
│ ├── components/ # Shared React components
│ ├── lib/
│ │ ├── store.ts # Persistent settings (tauri-plugin-store)
│ │ ├── logger.ts # Logging utilities
│ │ ├── permissions.ts # OS permission checks
│ │ └── utils.ts # Helper functions
│ └── main.tsx # Entry point (routes to widget/settings)
├── src-tauri/
│ ├── src/
│ │ ├── lib.rs # Tauri commands, window management
│ │ ├── ai.rs # Whisper + LLM API calls
│ │ ├── paste.rs # Clipboard paste simulation
│ │ └── logger.rs # File logging
│ └── Cargo.toml
└── package.json
```
## Build Commands
```bash
# Development (hot reload)
bun run tauri dev
# Build for production
bun run tauri build
# TypeScript check
bunx tsc --noEmit
# Rust check
cd src-tauri && cargo check
# View logs
bun run logs # tail -f ~/.talkflow/talkflow.log
bun run logs:clear # rm ~/.talkflow/talkflow.log
```
## Code Style
### TypeScript/React
**Imports:** Group by external → internal, use explicit file extensions for clarity when needed.
```typescript
// External
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
// Internal - use relative paths
import { getSettings } from "../../lib/store";
import { logInfo } from "../../lib/logger";
```
**Components:** Use function components with explicit return types.
```typescript
export function MyComponent({ prop }: { prop: string }): JSX.Element {
// hooks at the top
const [state, setState] = useState<string>("");
// early returns for loading/error states
if (!state) return null;
// main render
return <div>{prop}</div>;
}
```
**Styles:** Use inline styles with CSS variables. No CSS modules or styled-components.
```typescript
// Good
<div style={{
display: "flex",
padding: "16px 20px",
background: "var(--surface)",
}}>
```
**Types:** Prefer explicit interfaces over type aliases. Use union types for finite states.
```typescript
type WidgetState = "idle" | "recording" | "processing" | "error";
interface AppSettings {
apiKey: string;
hotkey: string;
// ...
}
```
**Error handling:** Always handle errors gracefully, show user-friendly messages.
```typescript
try {
await someAsyncOperation();
} catch (e) {
const msg = e instanceof Error ? e.message : "Unknown error";
showError(`Операция не удалась: ${msg}`);
}
```
**Logging:** Use the logger utility for important events.
```typescript
import { logInfo, logError } from "../../lib/logger";
logInfo("HOTKEY", "Registered successfully");
logError("API", `Failed: ${e}`);
```
### Rust
**Commands:** Use `#[tauri::command]` with async when needed.
```rust
#[tauri::command]
pub async fn my_command(param: String) -> Result<MyResponse, String> {
// implementation
}
```
**Error handling:** Use `Result<T, String>` for commands, convert errors with `.map_err(|e| e.to_string())`.
```rust
let result = some_operation()
.map_err(|e| format!("Operation failed: {}", e))?;
```
**Logging:** Use the logger module.
```rust
logger::log_info("TAG", "message");
logger::log_error("TAG", &format!("error: {}", e));
```
## Architecture Notes
- **Two windows:** Widget (44x44px floating) and Settings (separate window)
- **Global shortcuts:** Use `tauri-plugin-global-shortcut` for hotkey registration
- **Persistent storage:** Use `tauri-plugin-store` with JSON file
- **Permissions:** Check microphone via `getUserMedia()`, accessibility via system dialog
- **API calls:** Whisper for transcription, GPT-4o-mini for text cleanup
## Key Conventions
1. **Language:** UI text in Russian, code/comments in English
2. **Hotkeys:** Format is `Modifier+Key` (e.g., `Ctrl+Alt+Space`), always validate with `validateHotkey()`
3. **Settings:** Load once at startup via `getSettings()`, save immediately on change
4. **Window sizes:** Widget is 44x44 (idle) or 220x140 (recording)
5. **Logs location:** `~/.talkflow/talkflow.log`

180
README.md Normal file
View file

@ -0,0 +1,180 @@
# TalkFlow
TalkFlow is a lightweight macOS voice-to-text app built with Tauri.
It sits in a small floating widget, listens while you hold a hotkey, sends audio to Whisper for transcription, cleans up the text with GPT-4o mini, and pastes the result into the active app.
## What it does
- Hold `Ctrl + Alt + Space` to start recording
- Release the hotkey to stop and process the audio
- The recognized text is pasted automatically into the current app
- A second press during recording locks the recording mode
- The settings window lets you choose language, microphone, API key, and text cleanup style
- Recent recordings are saved in local history
## macOS only
TalkFlow is currently designed for macOS.
The app relies on:
- microphone access
- accessibility permission for automatic text pasting
- a global hotkey
## Setup
Before first use, make sure you have:
1. macOS
2. an OpenAI API key
3. microphone access enabled
4. accessibility access enabled for TalkFlow
### 1. Open the settings window
When the app starts, it opens the settings window automatically.
If it is hidden, click the floating widget to open it again.
### 2. Grant permissions
On first launch, TalkFlow asks for:
- Microphone access - required for recording
- Accessibility access - required to paste the final text into other apps
Without accessibility permission, speech can still be processed, but automatic paste may not work correctly.
### 3. Add your OpenAI API key
Open the `Subscription` tab and paste your OpenAI API key.
Right now TalkFlow works with your own API key only.
The key is used for:
- `whisper-1` for speech recognition
- `gpt-4o-mini` for text cleanup
## How to use
### Basic flow
1. Focus any app or text field where you want to insert text
2. Hold `Ctrl + Alt + Space`
3. Start speaking
4. Release the hotkey when finished
5. Wait a moment while TalkFlow processes the audio
6. The cleaned text is pasted automatically
### Locked recording mode
If you want to speak longer without holding the keys:
1. Press and hold `Ctrl + Alt + Space`
2. While recording is active, press the hotkey again
3. Recording becomes locked
4. Press the hotkey once more to stop and process
### Settings you can change
- Recognition language
- Input microphone
- Text cleanup style
- OpenAI API key
## Text styles
TalkFlow supports several cleanup styles for the final text. The exact labels may evolve, but the idea is simple:
- `Classic` - neutral cleanup for everyday dictation
- `Business` - cleaner and more formal phrasing
- `Tech` - better suited for technical language and terms
## History
The `Main` tab stores recent recordings locally so you can:
- review previous results
- copy text again
- delete individual entries
- clear the full history
History is stored locally on your machine.
## Privacy
- Audio is sent to the API endpoints you configure for transcription and cleanup
- By default, TalkFlow uses OpenAI endpoints
- Your API key is stored locally in the app settings
- TalkFlow does not require a TalkFlow account
## Advanced configuration
TalkFlow supports custom compatible endpoints for:
- Whisper transcription
- chat completion / cleanup
If these fields are left empty, the app uses the standard OpenAI API.
## Troubleshooting
### Nothing gets pasted
- Check that TalkFlow has Accessibility permission in macOS System Settings
- Make sure the target app allows normal paste input
- Try again in a standard text field like Notes
### The microphone list is empty
- Grant microphone permission in macOS
- Reopen the settings window
- Reconnect your audio device if you use an external microphone
### The hotkey does not trigger
- Make sure another app is not using the same shortcut
- Restart TalkFlow after changing macOS permissions
### Build fails on external drives with `._*` files
macOS can create AppleDouble metadata files on some external volumes. If Tauri fails while reading files like `._default.json` or `._default.toml`, remove them:
```bash
find . -name '._*' -delete
```
This repository also uses `.cargo/config.toml` to keep Cargo build artifacts off the external drive.
## Development
If you want to run the project locally:
```bash
bun install
bun run tauri dev
```
Useful commands:
```bash
bunx tsc --noEmit
bun run logs
bun run logs:clear
```
## Tech stack
- Tauri v2
- React
- TypeScript
- Rust
- OpenAI Whisper
- OpenAI GPT-4o mini
## Status
TalkFlow is an active work in progress. Expect rough edges while the interaction model and onboarding continue to improve.

385
bun.lock Normal file
View file

@ -0,0 +1,385 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "talk-flow",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2.4.2",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",
"vite": "^7.0.4",
},
},
},
"packages": {
"@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-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-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@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-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@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-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/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=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@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=="],
"@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=="],
"@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-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="],
"@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
"@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="],
"@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@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=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"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=="],
"caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"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=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"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=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"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=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@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=="],
}
}

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TalkFlow</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "talk-flow",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "bun@1.2.13",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"check": "bunx tsc --noEmit && cargo check --manifest-path src-tauri/Cargo.toml",
"test": "bun test",
"smoke:hotkey": "bun test src/windows/widget/services/hotkeyFsm.test.js",
"check:release": "bun run check && bun run smoke:hotkey && bun run build",
"preview": "vite preview",
"tauri": "tauri",
"logs": "tail -f ~/.talkflow/talkflow.log",
"logs:clear": "rm -f ~/.talkflow/talkflow.log"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2.4.2",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

6
public/tauri.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

726
site/index.html Normal file
View file

@ -0,0 +1,726 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TalkFlow — Pure Thought</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Унифицированные шрифты (как в приложении) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Manrope:wght@600;700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
<style>
:root {
--font-main: 'Inter', sans-serif;
--font-accent: 'Playfair Display', serif;
--font-brand: 'Manrope', 'Inter', sans-serif;
--bg-cappuccino: #FAF9F6;
}
body {
font-family: var(--font-main);
background-color: var(--bg-cappuccino);
color: #000000;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
h1, h2, .font-serif, .font-accent { font-family: var(--font-accent); }
.brand-wordmark {
font-family: var(--font-brand);
letter-spacing: -0.06em;
font-weight: 800;
}
/* Фиксированная навигация с адаптивным фоном */
.sticky-nav {
position: sticky;
top: 0;
z-index: 100;
background-color: rgba(250, 249, 246, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
}
.btn-black {
background-color: #000000;
color: #ffffff;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.75rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-black:hover {
background-color: #222;
transform: translateY(-1px);
}
/* Анимированный логотип */
.voice-logo {
display: flex;
align-items: center;
gap: 3px;
height: 24px;
}
.voice-logo-bar {
width: 3px;
background-color: #000000;
border-radius: 10px;
animation: voicePulse 1.2s ease-in-out infinite;
}
.voice-logo-bar:nth-child(1) { height: 8px; animation-delay: 0.1s; }
.voice-logo-bar:nth-child(2) { height: 16px; animation-delay: 0.3s; }
.voice-logo-bar:nth-child(3) { height: 12px; animation-delay: 0.2s; }
.voice-logo-bar:nth-child(4) { height: 20px; animation-delay: 0.4s; }
.voice-logo-bar:nth-child(5) { height: 10px; animation-delay: 0.5s; }
@keyframes voicePulse {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.6); }
}
/* Magic Cleanup: Strikethrough Concept */
.word-span {
display: inline-block;
margin-right: 8px;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
color: #b5b0a8;
}
.word-trash { opacity: 0.25; }
.word-trash::after {
content: '';
position: absolute;
left: 0;
top: 55%;
width: 100%;
height: 1px;
background: #000;
transform: scaleX(0);
transform-origin: left;
transition: transform 0.1s ease-out;
}
.word-trash.crossed::after { transform: scaleX(1); }
.word-keep { color: #000; font-weight: 500; }
.flow-arrow {
width: 1px; height: 40px;
background-color: #000;
position: relative;
margin: 20px 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.flow-arrow::after {
content: ''; position: absolute; bottom: 0; left: 50%;
width: 6px; height: 6px;
border-right: 1px solid #000; border-bottom: 1px solid #000;
transform: translateX(-50%) rotate(45deg);
}
.flow-active .flow-arrow { opacity: 0.15; }
.message-bubble {
background: #000;
color: #fff;
padding: 22px 30px;
border-radius: 22px 22px 0 22px;
display: inline-block;
max-width: 95%;
position: relative;
text-align: left;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.8s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.message-bubble.visible { opacity: 1; transform: translateY(0); }
.message-bubble::after {
content: ''; position: absolute; bottom: 0; right: -8px;
width: 0; height: 0; border-left: 10px solid #000; border-top: 10px solid transparent;
}
.status-checks { display: flex; justify-content: flex-end; margin-top: 8px; opacity: 0.4; }
/* Audio Waves */
.wave-bar {
width: 3px;
background-color: #ffffff;
margin: 0 2px;
border-radius: 10px;
transition: height 0.1s ease;
height: 4px;
}
.reveal-node {
opacity: 0;
transform: translateY(20px);
transition: opacity 1s cubic-bezier(0.2, 0.8, 0.2, 1), transform 1s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.reveal-node.active { opacity: 1; transform: translateY(0); }
/* Modal styling */
#modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(12px);
z-index: 10000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
background: white;
padding: 2.5rem 1.5rem;
max-width: 540px;
width: 100%;
position: relative;
box-shadow: 0 40px 80px -12px rgba(0,0,0,0.25);
max-height: 95vh;
overflow-y: auto;
}
@media (min-width: 768px) {
.modal-content {
padding: 3.5rem;
}
}
.form-input {
border-bottom: 1px solid #eee;
padding: 18px 0;
width: 100%;
outline: none;
transition: border-color 0.4s;
font-size: 1rem;
margin-bottom: 1.5rem;
background: transparent;
}
.form-input:focus { border-color: #000; }
/* Scroll */
.scroll-container {
display: flex;
width: max-content;
animation: scroll 45s linear infinite;
}
@keyframes scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.integration-item {
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #9c978e;
border-right: 1px solid rgba(0,0,0,0.03);
}
@media (min-width: 768px) {
.integration-item {
padding: 0 40px;
font-size: 0.8rem;
letter-spacing: 0.15em;
}
}
.cursor { display: inline-block; width: 2px; height: 1.1em; background-color: currentColor; margin-left: 2px; vertical-align: middle; animation: blink 1s infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.key-unit { width: 12px; height: 12px; border: 1px solid #f0f0f0; transition: all 0.1s ease; }
@media (min-width: 768px) {
.key-unit { width: 16px; height: 16px; }
}
.key-unit.active { background-color: #000; border-color: #000; }
#voice-text span {
display: inline;
opacity: 0;
filter: blur(5px);
transition: all 0.6s ease;
}
#voice-text span.visible { opacity: 1; filter: blur(0); }
/* Updated Features List */
.features-list li {
position: relative;
margin-bottom: 10px;
font-size: 0.85rem;
line-height: 1.5;
color: #555;
}
/* Corporate-specific List with Dashes */
.features-list-corporate li {
position: relative;
padding-left: 20px;
margin-bottom: 12px;
font-size: 0.85rem;
line-height: 1.5;
}
.features-list-corporate li::before {
content: '—';
position: absolute;
left: 0;
color: rgba(255,255,255,0.4);
}
</style>
</head>
<body class="selection:bg-black selection:text-white">
<!-- Навигация -->
<nav class="sticky-nav px-4 py-4 md:px-10 md:py-6">
<div class="max-w-7xl mx-auto flex justify-between items-center">
<div class="flex items-center space-x-4 md:space-x-6">
<div class="voice-logo">
<div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div>
</div>
<div class="brand-wordmark text-lg md:text-xl tracking-tighter text-black uppercase">TalkFlow.</div>
</div>
<div class="flex items-center space-x-4 md:space-x-12">
<div class="hidden lg:flex items-center space-x-12 text-[10px] uppercase tracking-[0.2em] font-bold">
<a href="#integrations" class="hover:text-gray-400 transition-colors text-black/50">Интеграции</a>
<a href="#speed" class="hover:text-gray-400 transition-colors text-black/50">Скорость</a>
<a href="#options" class="hover:text-gray-400 transition-colors text-black/50">Варианты</a>
</div>
<a href="#" class="btn-black px-4 py-2 md:px-6 md:py-2.5 rounded-full flex items-center space-x-2">
<span class="platform-icon"></span>
<span class="text-[9px] md:text-[10px]">скачать</span>
</a>
</div>
</div>
</nav>
<!-- ГЛАВНЫЙ БЛОК -->
<header class="max-w-5xl mx-auto px-6 pt-16 pb-24 md:pt-40 md:pb-48 text-center reveal-node">
<h1 class="text-4xl sm:text-5xl md:text-7xl lg:text-[100px] font-bold leading-[1.1] md:leading-[1] mb-8 md:mb-10 tracking-tighter text-black">
Думайте вслух. <br>
<span class="italic font-accent text-gray-300">Мы запишем.</span>
</h1>
<p class="text-base md:text-xl text-gray-500 max-w-xl mx-auto mb-12 md:mb-16 leading-relaxed font-normal">
Мгновенная трансформация ваших мыслей в структурированный текст. Работает везде, где есть курсор.
</p>
<div class="flex flex-col items-center justify-center space-y-6 md:space-y-8">
<button class="btn-black w-full sm:w-auto px-12 py-5 rounded-full text-[11px] shadow-xl flex items-center justify-center space-x-4">
<span class="platform-icon-large"></span>
<span>Скачать приложение</span>
</button>
<div class="text-[8px] md:text-[9px] uppercase tracking-[0.3em] md:tracking-[0.4em] text-gray-400 font-bold px-4 text-center leading-loose">
macOS &nbsp; Windows &nbsp; Linux &nbsp; Android &nbsp; iOS
</div>
</div>
</header>
<!-- ИНТЕГРАЦИИ -->
<section id="integrations" class="py-12 md:py-16 border-t border-b border-black/5 overflow-hidden reveal-node">
<div class="scroll-container">
<div class="integration-item">Telegram</div><div class="integration-item">Notion</div><div class="integration-item">Яндекс</div><div class="integration-item">Slack</div><div class="integration-item">VK Teams</div><div class="integration-item">Bitrix24</div><div class="integration-item">AmoCRM</div><div class="integration-item">Gmail</div><div class="integration-item">VS Code</div><div class="integration-item">Figma</div><div class="integration-item">Discord</div>
<div class="integration-item">Telegram</div><div class="integration-item">Notion</div><div class="integration-item">Яндекс</div><div class="integration-item">Slack</div><div class="integration-item">VK Teams</div><div class="integration-item">Bitrix24</div><div class="integration-item">AmoCRM</div><div class="integration-item">Gmail</div><div class="integration-item">VS Code</div><div class="integration-item">Figma</div><div class="integration-item">Discord</div>
</div>
</section>
<!-- МАГИЯ ОБРАБОТКИ -->
<section id="ai-demo" class="max-w-5xl mx-auto px-6 py-24 md:py-40 reveal-node text-center">
<div class="mb-12 md:mb-16">
<h2 class="text-3xl md:text-5xl font-bold italic mb-4 tracking-tighter text-black">Магия обработки</h2>
<p class="text-gray-500 text-sm md:text-lg max-w-xl mx-auto font-light leading-relaxed">Система автоматически очищает поток сознания от речевого шума.</p>
</div>
<div id="ai-flow-container" class="relative flex flex-col items-center">
<div id="dirty-container" class="text-lg md:text-2xl leading-relaxed font-normal max-w-2xl mx-auto transition-all duration-300 min-h-[100px] md:min-h-[120px]">
<!-- Words injected by JS -->
</div>
<div class="flow-arrow"></div>
<div id="clean-result" class="max-w-xl w-full flex justify-center">
<div class="message-bubble" id="target-bubble">
<div id="ai-output" class="text-base md:text-xl font-medium leading-snug">
<!-- Result from Gemini -->
</div>
<div class="status-checks">
<svg class="w-4 h-4 md:w-5 md:h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M7 13l3 3 7-7"/><path d="M2 13l3 3 7-7"/></svg>
</div>
</div>
</div>
</div>
</section>
<!-- СИНХРОННОЕ СРАВНЕНИЕ -->
<section id="speed" class="max-w-7xl mx-auto px-6 py-24 md:py-40 reveal-node">
<div class="mb-12 md:mb-16 flex flex-col md:flex-row justify-between items-start md:items-end gap-6 md:gap-8">
<div>
<h2 class="text-3xl md:text-5xl font-bold italic mb-3 tracking-tighter text-black uppercase">Синхронное сравнение</h2>
<p class="text-[10px] uppercase tracking-[0.2em] text-gray-500 font-bold">Один и тот же текст. Разные измерения.</p>
</div>
<button id="start-demo" class="text-[10px] w-full md:w-auto uppercase tracking-[0.2em] border border-black px-8 py-3 hover:bg-black hover:text-white transition-all font-bold">Перезапустить тест</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-px bg-black/5 border border-black/5 min-h-[400px] md:min-h-[480px]">
<div class="bg-white p-6 sm:p-10 md:p-14 flex flex-col justify-between">
<div>
<div class="flex justify-between items-center mb-8 md:mb-10">
<span class="text-[9px] md:text-[10px] uppercase tracking-[0.2em] font-bold text-gray-300">Обычная печать</span>
<span class="text-[9px] md:text-[10px] text-gray-300 font-mono uppercase">~40 сл/мин</span>
</div>
<div id="keyboard-box" class="text-gray-400 text-sm md:text-xl leading-relaxed overflow-hidden font-mono min-h-[140px] md:min-h-[160px]">
<span id="keyboard-text"></span><span class="cursor"></span>
</div>
</div>
<div class="grid grid-cols-10 gap-1 md:gap-1.5 w-fit opacity-5 mt-8 md:mt-10" id="visual-keys"></div>
</div>
<div class="bg-black text-white p-6 sm:p-10 md:p-14 flex flex-col relative overflow-hidden">
<div class="flex justify-between items-center mb-8 md:mb-10 relative z-10">
<span class="brand-wordmark text-[9px] md:text-[10px] uppercase tracking-[0.2em] text-white opacity-50 uppercase">TalkFlow Engine</span>
<span class="text-[9px] md:text-[10px] text-gray-500 font-mono text-white uppercase">~150 сл/мин</span>
</div>
<div id="voice-box" class="flex-grow text-white text-xl md:text-3xl font-accent italic leading-tight overflow-hidden relative z-10 min-h-[140px] md:min-h-[160px]">
<div id="voice-text"></div>
</div>
<div class="absolute bottom-0 left-0 w-full h-32 md:h-40 flex items-end justify-center px-6 md:px-10 pointer-events-none opacity-20" id="waveform-container"></div>
</div>
</div>
</section>
<!-- ВАРИАНТЫ ИСПОЛЬЗОВАНИЯ -->
<section id="options" class="max-w-7xl mx-auto px-6 py-16 md:py-24 reveal-node">
<div class="mb-12 md:mb-20 text-center">
<h2 class="text-3xl md:text-4xl font-bold italic mb-4 tracking-tighter text-black uppercase">Варианты использования</h2>
<div class="w-12 md:w-16 h-[1px] bg-black mx-auto"></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[6fr_4fr] border border-black/10 overflow-hidden bg-white shadow-sm rounded-sm">
<!-- ПЕРСОНАЛЬНЫЙ (60%) -->
<div class="p-6 sm:p-10 md:p-14 lg:p-20 flex flex-col justify-between border-b lg:border-b-0 lg:border-r border-black/10">
<div class="mb-10 md:mb-12">
<h3 class="text-4xl md:text-6xl font-bold mb-10 md:mb-14 font-accent italic tracking-tighter text-black">Для себя</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-12">
<!-- Бесплатно -->
<div class="space-y-4 md:space-y-6">
<div class="pb-3 border-b border-gray-100">
<h4 class="text-xs md:text-sm uppercase font-bold tracking-widest text-black">Бесплатно</h4>
</div>
<ul class="features-list">
<li>Используйте свой личный ключ доступа к нейросетям</li>
<li>Полный контроль над расходами — платите только за использование</li>
<li>Ваши данные не проходят через наши серверы и не сохраняются</li>
<li>Создавайте неограниченное количество заметок и записей</li>
<li>Тонкая настройка инструкций для обработки под ваши задачи</li>
</ul>
</div>
<!-- По подписке -->
<div class="space-y-4 md:space-y-6">
<div class="pb-3 border-b border-gray-100">
<h4 class="text-xs md:text-sm uppercase font-bold tracking-widest text-black">По подписке</h4>
</div>
<ul class="features-list">
<li>Доступ к самым мощным и современным моделям интеллекта</li>
<li>Мгновенная синхронизация между всеми вашими устройствами</li>
<li>Автоматическое форматирование и выделение ключевых задач</li>
<li>Безопасное облачное хранилище для вашей истории мыслей</li>
<li>Приоритетная техническая поддержка в любое время</li>
</ul>
</div>
</div>
</div>
<button class="btn-black w-full py-5 rounded-full shadow-lg text-[10px] mt-8">Скачать приложение</button>
</div>
<!-- КОРПОРАЦИЯ (40%) -->
<div class="p-6 sm:p-10 md:p-14 lg:p-20 bg-black text-white flex flex-col justify-between">
<div>
<h3 class="text-4xl md:text-6xl font-bold mb-8 md:mb-12 font-accent italic tracking-tighter text-white">Корпорация</h3>
<p class="text-sm md:text-base text-gray-200 leading-relaxed mb-10 md:mb-12 font-normal">
Комплексное решение для команд. Интеграция в существующую инфраструктуру компании с соблюдением высших стандартов защиты данных.
</p>
<ul class="features-list-corporate text-gray-100 font-light">
<li>Удобная панель управления доступами для всех сотрудников</li>
<li>Возможность работы внутри закрытого контура вашей компании</li>
<li>Индивидуальные алгоритмы обработки для ваших бизнес-процессов</li>
<li>Единая система авторизации и полный аудит безопасности</li>
</ul>
</div>
<button id="open-modal-btn" class="bg-white text-black w-full py-5 rounded-full text-[11px] font-bold uppercase tracking-widest hover:bg-gray-100 transition-colors shadow-lg mt-8 md:mt-0">Связаться с нами</button>
</div>
</div>
</section>
<!-- МОДАЛЬНОЕ ОКНО -->
<div id="modal-overlay">
<div class="modal-content">
<button id="close-modal-btn" class="absolute top-6 right-6 text-3xl font-light hover:rotate-90 transition-transform">×</button>
<h3 class="text-3xl md:text-4xl font-bold mb-4 font-accent italic tracking-tighter text-black uppercase">Связаться</h3>
<p class="text-[9px] md:text-[10px] uppercase tracking-[0.2em] text-gray-400 mb-8 md:mb-12 uppercase font-bold">Оставьте заявку для вашей команды</p>
<form id="enterprise-form" class="space-y-2 md:space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8">
<input type="text" name="name" class="form-input" placeholder="Имя" required>
<input type="text" name="surname" class="form-input" placeholder="Фамилия" required>
</div>
<input type="email" name="email" class="form-input" placeholder="Электронная почта" required>
<input type="tel" name="phone" class="form-input" placeholder="Номер телефона (+7...)" required>
<button type="submit" class="btn-black w-full py-5 rounded-full mt-6 md:mt-8 uppercase">Отправить данные</button>
</form>
<div id="form-success" class="hidden text-center py-10 md:py-12">
<div class="text-4xl md:text-5xl mb-6 text-black"></div>
<p class="text-[10px] md:text-[11px] uppercase tracking-[0.2em] font-bold text-black uppercase">Данные отправлены.<br>Ожидайте звонка.</p>
</div>
</div>
</div>
<!-- ПОДВАЛ -->
<footer class="py-16 md:py-24 text-center border-t border-black/5 mt-12 flex flex-col items-center">
<div class="flex items-center space-x-4 md:space-x-6">
<div class="voice-logo">
<div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div><div class="voice-logo-bar"></div>
</div>
<div class="brand-wordmark text-2xl md:text-3xl tracking-tighter text-black uppercase">TalkFlow.</div>
</div>
</footer>
<script type="module">
const textToCompare = "В современном мире мысль часто теряется в процессе механического набора. TalkFlow освобождает ваш разум от клавиатуры, превращая голос в идеально структурированный текст мгновенно.";
const voiceChunks = ["В современном мире мысль часто теряется...", "в процессе механического набора.", "TalkFlow освобождает ваш разум от клавиатуры,", "превращая голос в идеально структурированный текст мгновенно."];
const rawDictation = "слушай... ну эээ короч зафтра... надо... в 9 встретица... типа... обсудить... проект феникс... ну и доки... захвати... лан? ага";
const noiseWords = ["ну", "эээ", "короч", "типа...", "лан?", "ага", "ну", "и"];
const platformIcons = {
mac: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17.073 21.318c-.71.533-1.42 1.033-2.13 1.033-.82 0-1.11-.533-2.11-.533-1 0-1.33.511-2.12.511-.78 0-1.5-.555-2.22-1.089-1.533-1.088-2.61-3.11-2.61-5.066 0-3.088 2.01-4.733 3.966-4.733.988 0 1.833.644 2.455.644.6 0 1.577-.689 2.688-.689 1.156 0 2.223.511 2.91 1.378-2.31 1.355-1.956 4.6 0 5.866-.556 1.4-1.289 2.822-2.244 3.678M13.682 7.15c.5-1.355-.389-2.733-1.422-3.666-1.123-1-2.634-1.123-3.212-1.123-.11.8.211 2.256 1.256 3.322.955 1.044 2.533 1.155 3.378 1.467"></path></svg>`,
win: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0V3.449zM0 12.45h9.75v9.451L0 20.551V12.45zm10.549-10.559L24 0v11.551h-13.451V1.891zM24 12.45V24l-13.451-1.891V12.45H24z"></path></svg>`,
linux: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12.422 1c-1.332 0-2.43.914-2.43 2.04 0 .428.16.822.433 1.144-.754.267-1.463.763-2.008 1.43-.807.986-1.216 2.37-1.216 3.903 0 .49.043.956.126 1.396-.583.52-1.066 1.216-1.376 1.992-1.488 3.72-.037 8.32 1.764 10.435.534.626 1.205 1.05 1.95 1.25.742.2 1.542.235 2.336.102 1.19-.2 2.308-.737 3.238-1.554.93.817 2.048 1.355 3.238 1.554.793.133 1.593.1 2.335-.102.744-.2 1.415-.624 1.95-1.25 1.8-2.115 3.25-6.716 1.763-10.435-.31-.776-.793-1.472-1.376-1.992.083-.44.126-.906.126-1.396 0-1.533-.409-2.917-1.216-3.903-.545-.667-1.254-1.163-2.008-1.43.273-.322.433-.716.433-1.144 0-1.126-1.098-2.04-2.43-2.04h-.23z"></path></svg>`,
mobile: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"></path></svg>`
};
let cleanupStarted = false;
let isAnimating = false;
let animationIntervals = [];
// --- GLOBAL UTILS ---
window.detectPlatform = function() {
const ua = window.navigator.userAgent.toLowerCase();
let p = 'win';
if(ua.indexOf("mac")!==-1)p='mac';
else if(ua.indexOf("linux")!==-1)p='linux';
else if(ua.indexOf("android")!==-1||ua.indexOf("iphone")!==-1)p='mobile';
document.querySelectorAll('.platform-icon').forEach(el=>el.innerHTML=platformIcons[p]);
document.querySelectorAll('.platform-icon-large').forEach(el=>{
el.innerHTML=platformIcons[p];
const s=el.querySelector('svg');
if(s)s.setAttribute('class','w-5 h-5 md:w-6 md:h-6');
});
}
window.toggleModal = function(show) {
const modal = document.getElementById('modal-overlay');
if (modal) {
modal.style.display = show ? 'flex' : 'none';
document.body.style.overflow = show ? 'hidden' : 'auto';
}
}
function clearAllIntervals() {
animationIntervals.forEach(id => clearInterval(id));
animationIntervals = [];
}
function animateKeys() {
const keys = document.querySelectorAll('.key-unit');
if (keys.length === 0) return;
const randomKey = keys[Math.floor(Math.random() * keys.length)];
if (randomKey) {
randomKey.classList.add('active');
setTimeout(() => randomKey.classList.remove('active'), 100);
}
}
// --- MAGIC CLEANUP ---
window.runMagicCleanup = async function() {
if (cleanupStarted) return;
cleanupStarted = true;
const container = document.getElementById('dirty-container');
const flowContainer = document.getElementById('ai-flow-container');
const cleanResult = document.getElementById('clean-result');
const bubble = document.getElementById('target-bubble');
const outputEl = document.getElementById('ai-output');
const apiKey = "";
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
const systemPrompt = "Ты — TalkFlow AI. Преврати грязную диктовку в ОДНУ лаконичную и четкую фразу. Только текст.";
const apiPromise = fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: rawDictation }] }],
systemInstruction: { parts: [{ text: systemPrompt }] }
})
}).then(r => r.json());
flowContainer.classList.add('flow-active');
const spans = document.querySelectorAll('.word-span');
spans.forEach((span, i) => {
setTimeout(() => {
const isTrash = noiseWords.includes(span.innerText.trim().toLowerCase());
if (isTrash) {
span.classList.add('word-trash');
setTimeout(() => span.classList.add('crossed'), 50);
} else {
span.classList.add('word-keep');
}
if (i === spans.length - 1) {
setTimeout(async () => {
try {
const data = await apiPromise;
outputEl.innerText = data.candidates?.[0]?.content?.parts?.[0]?.text || "Нужно подготовить отчет к среде и позвонить Ивану.";
cleanResult.classList.remove('opacity-0');
bubble.classList.add('visible');
} catch (e) {
outputEl.innerText = "Завтра в 9:00 встреча по проекту. Не забудь документы.";
cleanResult.classList.remove('opacity-0');
bubble.classList.add('visible');
}
}, 400);
}
}, i * 180);
});
}
// --- SPEED COMPARISON ---
window.startComparison = async function() {
if (isAnimating) clearAllIntervals();
isAnimating = true;
const keyboardEl = document.getElementById('keyboard-text');
const voiceEl = document.getElementById('voice-text');
const waveContainer = document.getElementById('waveform-container');
keyboardEl.innerText = "";
voiceEl.innerHTML = "";
waveContainer.innerHTML = "";
const waveCount = window.innerWidth < 768 ? 20 : 30;
for(let i=0; i<waveCount; i++) {
const b = document.createElement('div');
b.className = 'wave-bar';
waveContainer.appendChild(b);
}
const waveBars = document.querySelectorAll('.wave-bar');
voiceChunks.forEach(chunk => {
const span = document.createElement('span');
span.innerText = chunk + " ";
voiceEl.appendChild(span);
});
const voiceSpans = voiceEl.querySelectorAll('span');
let charIndex = 0;
const kInterval = setInterval(() => {
if (charIndex < textToCompare.length) {
keyboardEl.innerText += textToCompare[charIndex];
if (textToCompare[charIndex] !== " ") animateKeys();
charIndex++;
} else clearInterval(kInterval);
}, 75);
animationIntervals.push(kInterval);
const waveInterval = setInterval(() => {
waveBars.forEach(bar => {
const maxH = window.innerWidth < 768 ? 40 : 60;
bar.style.height = (Math.random() * maxH + 5) + 'px';
});
}, 100);
animationIntervals.push(waveInterval);
let chunkIndex = 0;
const vInterval = setInterval(() => {
if (chunkIndex < voiceSpans.length) {
voiceSpans[chunkIndex].classList.add('visible');
chunkIndex++;
} else {
clearInterval(vInterval);
clearInterval(waveInterval);
waveBars.forEach(b => b.style.height = '4px');
isAnimating = false;
}
}, 700);
animationIntervals.push(vInterval);
}
// --- OBSERVERS ---
window.setupObservers = function() {
const nodeObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { if (entry.isIntersecting) entry.target.classList.add('active'); });
}, { threshold: 0.1 });
document.querySelectorAll('.reveal-node').forEach(node => nodeObserver.observe(node));
const aiObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { if (entry.isIntersecting) window.runMagicCleanup(); });
}, { threshold: 0.5 });
aiObserver.observe(document.getElementById('ai-demo'));
const speedObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { if (entry.isIntersecting && !isAnimating) window.startComparison(); });
}, { threshold: 0.3 });
speedObserver.observe(document.getElementById('speed'));
};
// --- INIT ---
window.onload = () => {
window.detectPlatform();
const openModalBtn = document.getElementById('open-modal-btn');
const closeModalBtn = document.getElementById('close-modal-btn');
if (openModalBtn) openModalBtn.onclick = () => window.toggleModal(true);
if (closeModalBtn) closeModalBtn.onclick = () => window.toggleModal(false);
const modalOverlay = document.getElementById('modal-overlay');
if (modalOverlay) {
modalOverlay.onclick = (e) => { if(e.target === modalOverlay) window.toggleModal(false); };
}
const keysContainer = document.getElementById('visual-keys');
const keyCount = window.innerWidth < 768 ? 15 : 30;
for (let i = 0; i < keyCount; i++) {
const key = document.createElement('div');
key.className = 'key-unit';
keysContainer.appendChild(key);
}
const container = document.getElementById('dirty-container');
const words = rawDictation.split(' ');
container.innerHTML = words.map(w => `<span class="word-span">${w}</span>`).join(' ');
window.setupObservers();
};
const form = document.getElementById('enterprise-form');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
form.classList.add('hidden');
document.getElementById('form-success').classList.remove('hidden');
});
}
const startDemoBtn = document.getElementById('start-demo');
if (startDemoBtn) {
startDemoBtn.addEventListener('click', window.startComparison);
}
</script>
</body>
</html>

68
site/style-rule.md Normal file
View file

@ -0,0 +1,68 @@
TalkFlow Website Style Prompt (актуально)
Используй этот промпт для генерации новых блоков или редактирования существующих секций лендинга TalkFlow.
Цель: визуально и типографически синхронизировать сайт с приложением (desktop UI).
1) Бренд-идея
- Концепция: Pure Thought / "Чистая мысль".
- Тон: спокойный, премиальный, технологичный без визуального шума.
- Визуальная модель: швейцарский минимализм + мягкая "бумажная" теплая подложка.
2) Обязательная типографика (как в приложении)
- Базовый sans: Inter (300, 400, 500, 600, 700).
- Брендовый sans для wordmark/логотипа: Manrope (600, 700, 800).
- Акцентный serif: Playfair Display (700, italic для эмоциональных акцентов).
- Не использовать Jost/Roboto/system-only как основу.
Подключение шрифтов (Google Fonts):
`Inter + Manrope + Playfair Display`.
Роли шрифтов:
- Основной текст, формы, кнопки, интерфейсные подписи: Inter.
- Название бренда "TalkFlow" и "TalkFlow Engine": Manrope.
- Акцентные фразы и эмоциональные смысловые куски в hero/демо: Playfair Display.
3) Цвета и токены (синхрон с приложением)
- Базовый фон: #FAF9F6.
- Мягкий фон: #F4F1EB.
- Основной текст: #000000.
- Средний текст: #39342D.
- Слабый текст: #5D564D.
- Акцент: #000000 / #FFFFFF.
- Danger (служебный): #8F2D20.
Границы и поверхности:
- Бордеры: rgba(0, 0, 0, 0.09) и rgba(0, 0, 0, 0.16) для усиленных состояний.
- Surface: rgba(255, 255, 255, 0.72) с blur-эффектом.
4) Формы, радиусы, сетка
- Общая пластика: скругления мягкие, без агрессивных углов.
- Радиусы: 8 / 12 / 16 px и pill = 999 px.
- Bubble радиус: 22px 22px 0 22px.
- Сложные секции: пропорция 60/40 допустима и поощряется.
5) Компонентные правила
- Кнопки: черные pill-кнопки, uppercase, плотный трекинг, уверенный контраст.
- Навигация: sticky + backdrop blur (~20px), тонкая граница снизу.
- Карточки/панели: светлые полупрозрачные поверхности, минимум теней.
- Списки: персональные без маркеров; корпоративные с длинным тире (—).
- Бабблы сообщений: черный фон, белый текст, двойные чек-марки в статусе.
6) Анимация и динамика
- Только осмысленные микродвижения: reveal, pulse, cleanup-flow.
- Magic Cleanup: поэтапное зачеркивание шумовых слов (около 180мс шаг).
- Лого-эквалайзер: 5 полос с разными задержками, scaleY пульсация.
- Избегать "перегруженных" эффектов и декоративной анимации без функциональной роли.
7) Адаптив
- Mobile-first: один столбец, аккуратное уменьшение типографики, без ломки ритма.
- На мобильном не терять контраст/читабельность, особенно в черных секциях.
8) Жесткие ограничения
- Не уводить палитру в фиолетовые/неоновые схемы.
- Не заменять брендовые шрифты на случайные аналоги.
- Не добавлять тяжелые тени "ради красоты".
- Не смешивать разные визуальные системы в одной секции.
9) Готовый промпт для генерации блока
"Сгенерируй секцию лендинга TalkFlow в стиле Pure Thought. Используй типографику как в приложении: Inter для UI-текста, Manrope для wordmark, Playfair Display для акцентов. Палитра: #FAF9F6 фон, #000000 акцент, #39342D вторичный текст. Поверхности полупрозрачные, тонкие бордеры, pill-кнопки, аккуратная анимация reveal. Сохрани мобильную адаптацию и контраст."

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

6615
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

33
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "talk-flow"
version = "0.1.0"
description = "TalkFlow - Voice to Text macOS Widget"
authors = ["trixter"]
edition = "2021"
[lib]
name = "talk_flow_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-store = "2"
tauri-plugin-clipboard-manager = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
enigo = "0.2"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "multipart"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
window-vibrancy = "0.7.1"
dirs = "5"
include_dir = "0.7"
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = { version = "0.3", features = ["NSView", "NSWindow"] }

19
src-tauri/Info.plist Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Предоставьте доступ к микрофону, чтобы TalkFlow мог записывать ваш голос для перевода в текст.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.trixter.talkflow</string>
<key>CFBundleURLSchemes</key>
<array>
<string>talkflow</string>
</array>
</dict>
</array>
</dict>
</plist>

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,27 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for all windows",
"windows": ["widget", "settings"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-show",
"opener:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"store:allow-load",
"store:allow-get",
"store:allow-set",
"store:allow-save",
"store:allow-clear",
"store:allow-delete",
"clipboard-manager:allow-write-text",
"clipboard-manager:allow-read-text"
]
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

269
src-tauri/src/ai.rs Normal file
View file

@ -0,0 +1,269 @@
use crate::logger;
use crate::prompt_config;
use base64::Engine;
use reqwest::multipart;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct TranscribeRequest {
pub audio_base64: String,
pub language: String,
pub api_key: String,
pub style: String,
pub whisper_endpoint: Option<String>,
pub llm_endpoint: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct TranscribeResponse {
pub raw: String,
pub cleaned: String,
}
fn build_whisper_prompt(language: &str) -> Option<&'static str> {
match language {
"ru" => Some(
"Это обычная русская речь. Корректно распознавай общеупотребительные слова: сегодня, сейчас, сегодняшний, также, тоже, ещё. Не дроби и не искажай слово 'сегодня'.",
),
"en" => Some(
"This is natural spoken English. Preserve common everyday words accurately and avoid splitting or distorting short common words.",
),
_ => None,
}
}
fn is_known_whisper_hallucination(text: &str) -> bool {
let normalized = text.trim().to_lowercase();
if normalized.is_empty() {
return true;
}
let has_subtitle_credit_pattern = normalized.contains("редактор субтитров")
|| normalized.contains("editor subs")
|| normalized.contains("subtitles by");
let has_proofreader_pattern = normalized.contains("корректор")
|| normalized.contains("proofread")
|| normalized.contains("correction by");
let has_known_name = normalized.contains("синецк") || normalized.contains("егоров");
(has_subtitle_credit_pattern && has_proofreader_pattern)
|| (has_subtitle_credit_pattern && has_known_name)
}
#[tauri::command]
pub async fn transcribe_and_clean(req: TranscribeRequest) -> Result<TranscribeResponse, String> {
logger::log_info("API", "Starting transcription...");
let client = reqwest::Client::new();
let audio_bytes = base64::engine::general_purpose::STANDARD
.decode(&req.audio_base64)
.map_err(|e| {
let err = format!("Base64 decode error: {}", e);
logger::log_error("API", &err);
err
})?;
// ── Step 1: Whisper Speech-to-Text ──────────────────────────────────
let whisper_url = req
.whisper_endpoint
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| {
let base = s.trim_end_matches('/');
if base.ends_with("/transcriptions") {
base.to_string()
} else if base.ends_with("/audio") {
format!("{}/transcriptions", base)
} else {
format!("{}/v1/audio/transcriptions", base)
}
})
.unwrap_or_else(|| "https://api.openai.com/v1/audio/transcriptions".to_string());
logger::log_info(
"WHISPER",
&format!(
"Sending request to {}, audio_size: {} bytes",
whisper_url,
audio_bytes.len()
),
);
let file_part = multipart::Part::bytes(audio_bytes)
.file_name("audio.webm")
.mime_str("audio/webm")
.map_err(|e| format!("MIME error: {}", e))?;
let lang_param = if req.language == "auto" {
String::new()
} else {
req.language.clone()
};
let mut form = multipart::Form::new()
.part("file", file_part)
.text("model", "whisper-1");
if let Some(prompt) = build_whisper_prompt(&req.language) {
form = form.text("prompt", prompt.to_string());
}
if !lang_param.is_empty() {
form = form.text("language", lang_param);
}
let whisper_res = client
.post(&whisper_url)
.bearer_auth(&req.api_key)
.multipart(form)
.send()
.await
.map_err(|e| {
let err = format!("Whisper request failed: {}", e);
logger::log_error("WHISPER", &err);
err
})?;
let status = whisper_res.status();
logger::log_info("WHISPER", &format!("Response status: {}", status));
if !status.is_success() {
let body = whisper_res.text().await.unwrap_or_default();
let err = format!("Whisper API error ({}): {}", status, body);
logger::log_error("WHISPER", &err);
return Err(err);
}
#[derive(Deserialize)]
struct WhisperResp {
text: String,
}
let whisper_body: WhisperResp = whisper_res.json().await.map_err(|e| {
let err = format!("Whisper response parse error: {}", e);
logger::log_error("WHISPER", &err);
err
})?;
let raw = whisper_body.text.trim().to_string();
logger::log_info("WHISPER", &format!("Transcribed: \"{}\"", raw));
if raw.is_empty() {
logger::log_info("WHISPER", "Empty transcription, returning empty response");
return Ok(TranscribeResponse {
raw: String::new(),
cleaned: String::new(),
});
}
if is_known_whisper_hallucination(&raw) {
logger::log_info(
"WHISPER",
&format!("Detected likely silence hallucination, dropping transcription: \"{}\"", raw),
);
return Ok(TranscribeResponse {
raw: String::new(),
cleaned: String::new(),
});
}
// ── Step 2: LLM Text Cleanup ────────────────────────────────────────
let prompt_preview = prompt_config::build_cleanup_prompt_preview(&req.language, &req.style)
.map_err(|err| {
logger::log_error("PROMPT", &err);
err
})?;
logger::log_info(
"PROMPT",
&format!(
"Using profile={} version={} layers={}",
prompt_preview.profile_key,
prompt_preview.version,
prompt_preview.layers.join(", ")
),
);
let system_prompt = prompt_preview.prompt;
// Always use gpt-4o-mini — fast, cheap, excellent for cleanup tasks
let llm_model = "gpt-4o-mini";
let llm_url = req
.llm_endpoint
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| {
let base = s.trim_end_matches('/');
if base.ends_with("/chat/completions") {
base.to_string()
} else if base.ends_with("/v1") {
format!("{}/chat/completions", base)
} else {
format!("{}/v1/chat/completions", base)
}
})
.unwrap_or_else(|| "https://api.openai.com/v1/chat/completions".to_string());
let gpt_body = serde_json::json!({
"model": llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": raw}
],
"temperature": 0.15,
"max_tokens": 4096
});
logger::log_info(
"LLM",
&format!("Sending to {}, model: {}", llm_url, llm_model),
);
let gpt_res = client
.post(&llm_url)
.bearer_auth(&req.api_key)
.header("Content-Type", "application/json")
.json(&gpt_body)
.send()
.await
.map_err(|e| {
let err = format!("LLM request failed: {}", e);
logger::log_error("LLM", &err);
err
})?;
let gpt_status = gpt_res.status();
logger::log_info("LLM", &format!("Response status: {}", gpt_status));
if !gpt_status.is_success() {
let body = gpt_res.text().await.unwrap_or_default();
let err = format!("LLM API error ({}): {}", gpt_status, body);
logger::log_error("LLM", &err);
return Err(err);
}
#[derive(Deserialize)]
struct Choice {
message: ChatMsg,
}
#[derive(Deserialize)]
struct ChatMsg {
content: String,
}
#[derive(Deserialize)]
struct GPTResp {
choices: Vec<Choice>,
}
let gpt_parsed: GPTResp = gpt_res.json().await.map_err(|e| {
let err = format!("LLM response parse error: {}", e);
logger::log_error("LLM", &err);
err
})?;
let cleaned = gpt_parsed
.choices
.first()
.map(|c| c.message.content.trim().to_string())
.unwrap_or_else(|| raw.clone());
logger::log_info("LLM", &format!("Cleaned: \"{}\"", cleaned));
logger::log_info("API", "Transcription complete");
Ok(TranscribeResponse { raw, cleaned })
}

195
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,195 @@
mod ai;
mod logger;
mod paste;
mod prompt_config;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> u8;
}
#[tauri::command]
async fn open_settings(app: AppHandle) -> Result<(), String> {
if let Some(win) = app.get_webview_window("settings") {
if let Err(err) = win.show() {
logger::log_error("WINDOW", &format!("Failed to show settings window: {}", err));
}
if let Err(err) = win.set_focus() {
logger::log_error("WINDOW", &format!("Failed to focus settings window: {}", err));
}
return Ok(());
}
let win = WebviewWindowBuilder::new(
&app,
"settings",
WebviewUrl::App("index.html?window=settings".into()),
)
.title("TalkFlow — Settings")
.inner_size(920.0, 680.0)
.min_inner_size(820.0, 560.0)
.decorations(false)
.transparent(true)
.center()
.build()
.map_err(|e| e.to_string())?;
#[cfg(target_os = "macos")]
if let Err(err) = apply_vibrancy(&win, NSVisualEffectMaterial::HudWindow, None, None) {
logger::log_error(
"WINDOW",
&format!("Failed to apply vibrancy to settings window: {}", err),
);
}
if let Err(err) = win.show() {
logger::log_error(
"WINDOW",
&format!("Failed to show new settings window: {}", err),
);
}
if let Err(err) = win.set_focus() {
logger::log_error(
"WINDOW",
&format!("Failed to focus new settings window: {}", err),
);
}
Ok(())
}
#[tauri::command]
async fn widget_resize(app: AppHandle, width: f64, height: f64) -> Result<(), String> {
if let Some(win) = app.get_webview_window("widget") {
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize { width, height })) {
logger::log_error("WINDOW", &format!("Failed to resize widget window: {}", err));
}
if let Ok(Some(monitor)) = win.primary_monitor() {
let screen_size = monitor.size();
let scale_factor = monitor.scale_factor();
let x = (screen_size.width as f64 / scale_factor - width) / 2.0;
let center_y = screen_size.height as f64 / scale_factor - 105.0;
let y = center_y - (height / 2.0);
if let Err(err) =
win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }))
{
logger::log_error("WINDOW", &format!("Failed to reposition widget window: {}", err));
}
}
}
Ok(())
}
#[tauri::command]
async fn open_accessibility_settings() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
.spawn()
.map_err(|e| format!("Failed to open accessibility settings: {}", e))?;
}
#[cfg(not(target_os = "macos"))]
{
// On Windows/Linux, accessibility is usually not required for global shortcuts
}
Ok(())
}
#[tauri::command]
async fn check_accessibility_permission() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
return Ok(unsafe { AXIsProcessTrusted() != 0 });
}
#[cfg(not(target_os = "macos"))]
{
Ok(true)
}
}
#[tauri::command]
fn get_cleanup_prompt_preview(
language: String,
style: String,
) -> Result<prompt_config::PromptPreview, String> {
prompt_config::build_cleanup_prompt_preview(&language, &style)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_clipboard_manager::init())
.setup(|app| {
logger::log_info("INIT", "Application starting...");
if let Some(win) = app.get_webview_window("widget") {
#[cfg(target_os = "macos")]
{
unsafe {
let ns_win: &objc2_app_kit::NSWindow =
&*win.ns_window().map_err(|e| e.to_string())?.cast();
ns_win.setAcceptsMouseMovedEvents(true);
}
}
let width = 56.0;
let height = 56.0;
if let Ok(Some(monitor)) = win.primary_monitor() {
let screen_size = monitor.size();
let scale_factor = monitor.scale_factor();
let x = (screen_size.width as f64 / scale_factor - width) / 2.0;
let center_y = screen_size.height as f64 / scale_factor - 105.0;
let y = center_y - (height / 2.0);
if let Err(err) =
win.set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }))
{
logger::log_error(
"WINDOW",
&format!("Failed to position widget window during setup: {}", err),
);
}
if let Err(err) = win.set_size(tauri::Size::Logical(tauri::LogicalSize {
width,
height,
})) {
logger::log_error(
"WINDOW",
&format!("Failed to size widget window during setup: {}", err),
);
}
}
}
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let _ = open_settings(handle).await;
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
open_settings,
widget_resize,
paste::paste_text,
ai::transcribe_and_clean,
logger::log_event,
logger::get_log_path_cmd,
logger::clear_logs,
open_accessibility_settings,
check_accessibility_permission,
get_cleanup_prompt_preview,
])
.run(tauri::generate_context!())
.expect("error while running TalkFlow");
}

64
src-tauri/src/logger.rs Normal file
View file

@ -0,0 +1,64 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
static LOG_MUTEX: Mutex<()> = Mutex::new(());
pub fn get_log_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".talkflow")
}
pub fn get_log_path() -> PathBuf {
get_log_dir().join("talkflow.log")
}
pub fn log(level: &str, tag: &str, message: &str) {
let _guard = LOG_MUTEX.lock().unwrap();
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let line = format!("[{}] [{}] [{}] {}\n", timestamp, level, tag, message);
if let Some(parent) = get_log_path().parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(mut file) = OpenOptions::new()
.create(true)
.append(true)
.open(get_log_path())
{
let _ = file.write_all(line.as_bytes());
}
println!("{}", line.trim());
}
pub fn log_info(tag: &str, message: &str) {
log("INFO", tag, message);
}
pub fn log_error(tag: &str, message: &str) {
log("ERROR", tag, message);
}
#[tauri::command]
pub fn log_event(level: String, tag: String, message: String) {
log(&level, &tag, &message);
}
#[tauri::command]
pub fn get_log_path_cmd() -> String {
get_log_path().to_string_lossy().to_string()
}
#[tauri::command]
pub fn clear_logs() -> Result<(), String> {
let path = get_log_path();
if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
Ok(())
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
talk_flow_lib::run()
}

67
src-tauri/src/paste.rs Normal file
View file

@ -0,0 +1,67 @@
use std::sync::mpsc;
use std::time::Duration;
use enigo::{Enigo, Key, Keyboard, Settings};
use crate::logger;
/// Paste text by writing to clipboard and simulating Cmd+V
#[tauri::command]
pub async fn paste_text(app: tauri::AppHandle, text: String) -> Result<(), String> {
let char_count = text.chars().count();
logger::log_info(
"PASTE",
&format!("Scheduling paste on main thread, chars={}", char_count),
);
let handle = app.clone();
let (tx, rx) = mpsc::channel::<Result<(), String>>();
app.run_on_main_thread(move || {
use tauri_plugin_clipboard_manager::ClipboardExt;
let result = (|| -> Result<(), String> {
logger::log_info(
"PASTE",
&format!("Writing text to clipboard, chars={}", char_count),
);
handle
.clipboard()
.write_text(text)
.map_err(|e| format!("Clipboard write failed: {}", e))?;
std::thread::sleep(Duration::from_millis(100));
logger::log_info("PASTE", "Simulating Cmd+V");
let mut enigo = Enigo::new(&Settings::default())
.map_err(|e| format!("Input initialization failed: {}", e))?;
enigo
.key(Key::Meta, enigo::Direction::Press)
.map_err(|e| format!("Meta press failed: {}", e))?;
enigo
.raw(0x09, enigo::Direction::Click)
.map_err(|e| format!("V click failed: {}", e))?;
enigo
.key(Key::Meta, enigo::Direction::Release)
.map_err(|e| format!("Meta release failed: {}", e))?;
Ok(())
})();
if let Err(err) = &result {
logger::log_error("PASTE", err);
} else {
logger::log_info("PASTE", "Paste completed on main thread");
}
let _ = tx.send(result);
})
.map_err(|e| format!("Failed to schedule paste on main thread: {}", e))?;
tokio::task::spawn_blocking(move || rx.recv())
.await
.map_err(|e| format!("Failed waiting for paste completion: {}", e))?
.map_err(|e| format!("Failed receiving paste result: {}", e))??;
Ok(())
}

View file

@ -0,0 +1,305 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use include_dir::{include_dir, Dir};
use serde::{Deserialize, Serialize};
static PROMPTS_DIR: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/../src/config/transcription-prompts");
static PROMPT_REGISTRY: OnceLock<Result<PromptRegistry, String>> = OnceLock::new();
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptPreview {
pub prompt: String,
pub layers: Vec<String>,
pub profile_key: String,
pub version: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PromptManifest {
version: u32,
default_language: String,
default_style: String,
base_file: String,
languages: HashMap<String, ManifestFileRef>,
styles: HashMap<String, ManifestStyleRef>,
overrides: HashMap<String, String>,
}
#[derive(Deserialize)]
struct ManifestFileRef {
file: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ManifestStyleRef {
file: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct BasePromptConfig {
assistant_name: String,
task: String,
core_rules: Vec<String>,
what_to_fix: Vec<String>,
what_not_to_do: Vec<String>,
output_rules: Vec<String>,
}
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct LanguagePromptConfig {
display_name: String,
filler_examples: Vec<String>,
rules: Vec<String>,
examples: Vec<PromptExample>,
}
#[derive(Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct StylePromptConfig {
prompt_title: String,
rules: Vec<String>,
examples: Vec<PromptExample>,
}
#[derive(Deserialize, Clone)]
struct PromptOverride {
rules: Vec<String>,
examples: Vec<PromptExample>,
}
#[derive(Deserialize, Clone)]
struct PromptExample {
raw: String,
cleaned: String,
}
struct PromptRegistry {
manifest: PromptManifest,
base: BasePromptConfig,
language_profiles: HashMap<String, LanguageProfile>,
style_profiles: HashMap<String, StyleProfile>,
override_profiles: HashMap<String, OverrideProfile>,
}
struct LanguageProfile {
file: String,
config: LanguagePromptConfig,
}
struct StyleProfile {
file: String,
config: StylePromptConfig,
}
struct OverrideProfile {
file: String,
config: PromptOverride,
}
pub fn build_cleanup_prompt_preview(language: &str, style: &str) -> Result<PromptPreview, String> {
let registry = get_prompt_registry()?;
let resolved_language = resolve_key(
language,
registry.manifest.languages.contains_key(language),
&registry.manifest.default_language,
);
let resolved_style = resolve_key(
style,
registry.manifest.styles.contains_key(style),
&registry.manifest.default_style,
);
let language_profile = registry
.language_profiles
.get(&resolved_language)
.ok_or_else(|| format!("Missing language prompt profile: {}", resolved_language))?;
let style_profile = registry
.style_profiles
.get(&resolved_style)
.ok_or_else(|| format!("Missing style prompt profile: {}", resolved_style))?;
let override_key = format!("{}:{}", resolved_language, resolved_style);
let override_profile = registry.override_profiles.get(&override_key);
let filler_examples = language_profile.config.filler_examples.join(", ");
let profile_key = override_profile
.map(|_| override_key.clone())
.unwrap_or_else(|| format!("{}:{}", resolved_language, resolved_style));
let mut layers = vec![
registry.manifest.base_file.clone(),
language_profile.file.clone(),
style_profile.file.clone(),
];
if let Some(profile) = override_profile {
layers.push(profile.file.clone());
}
let mut prompt = String::new();
prompt.push_str(&format!(
"You are {} — a professional speech-to-text post-processing assistant.\n\n",
registry.base.assistant_name
));
prompt.push_str(&format!(
"INPUT: Raw voice transcription dictated in {}.\n",
language_profile.config.display_name
));
prompt.push_str(&format!("TASK: {}\n\n", registry.base.task));
prompt.push_str("═══ CORE RULES ═══\n\n");
append_numbered_rules(&mut prompt, &registry.base.core_rules, &filler_examples);
if !language_profile.config.rules.is_empty() {
prompt.push_str("\n═══ LANGUAGE RULES ═══\n\n");
append_bulleted_rules(
&mut prompt,
&language_profile.config.rules,
&filler_examples,
);
}
if !style_profile.config.rules.is_empty() || override_profile.is_some() {
prompt.push_str(&format!(
"\n═══ {} ═══\n\n",
style_profile.config.prompt_title
));
append_bulleted_rules(&mut prompt, &style_profile.config.rules, &filler_examples);
if let Some(profile) = override_profile {
append_bulleted_rules(&mut prompt, &profile.config.rules, &filler_examples);
}
}
prompt.push_str("\n═══ WHAT TO FIX ═══\n\n");
append_bulleted_rules(&mut prompt, &registry.base.what_to_fix, &filler_examples);
prompt.push_str("\n═══ WHAT NOT TO DO ═══\n\n");
append_bulleted_rules(&mut prompt, &registry.base.what_not_to_do, &filler_examples);
let mut examples: Vec<PromptExample> = Vec::new();
examples.extend(language_profile.config.examples.clone());
examples.extend(style_profile.config.examples.clone());
if let Some(profile) = override_profile {
examples.extend(profile.config.examples.clone());
}
if !examples.is_empty() {
prompt.push_str("\n═══ EXAMPLES ═══\n\n");
for example in examples {
prompt.push_str(&format!(
"Input: {}\nOutput: {}\n\n",
render_rule(&example.raw, &filler_examples),
render_rule(&example.cleaned, &filler_examples)
));
}
}
prompt.push_str("═══ OUTPUT FORMAT ═══\n\n");
append_bulleted_rules(&mut prompt, &registry.base.output_rules, &filler_examples);
Ok(PromptPreview {
prompt,
layers,
profile_key,
version: registry.manifest.version,
})
}
fn get_prompt_registry() -> Result<&'static PromptRegistry, String> {
let result = PROMPT_REGISTRY.get_or_init(load_prompt_registry);
result.as_ref().map_err(Clone::clone)
}
fn load_prompt_registry() -> Result<PromptRegistry, String> {
let manifest: PromptManifest = read_json_file("manifest.json")?;
let base: BasePromptConfig = read_json_file(&manifest.base_file)?;
let mut language_profiles = HashMap::new();
for (key, value) in &manifest.languages {
let config = read_json_file(&value.file)?;
language_profiles.insert(
key.clone(),
LanguageProfile {
file: value.file.clone(),
config,
},
);
}
let mut style_profiles = HashMap::new();
for (key, value) in &manifest.styles {
let config = read_json_file(&value.file)?;
style_profiles.insert(
key.clone(),
StyleProfile {
file: value.file.clone(),
config,
},
);
}
let mut override_profiles = HashMap::new();
for (key, file) in &manifest.overrides {
let config = read_json_file(file)?;
override_profiles.insert(
key.clone(),
OverrideProfile {
file: file.clone(),
config,
},
);
}
Ok(PromptRegistry {
manifest,
base,
language_profiles,
style_profiles,
override_profiles,
})
}
fn append_numbered_rules(buffer: &mut String, rules: &[String], filler_examples: &str) {
for (index, rule) in rules.iter().enumerate() {
buffer.push_str(&format!(
"{}. {}\n",
index + 1,
render_rule(rule, filler_examples)
));
}
}
fn append_bulleted_rules(buffer: &mut String, rules: &[String], filler_examples: &str) {
for rule in rules {
buffer.push_str(&format!("- {}\n", render_rule(rule, filler_examples)));
}
}
fn render_rule(rule: &str, filler_examples: &str) -> String {
rule.replace("{filler_examples}", filler_examples)
}
fn resolve_key(requested: &str, exists: bool, fallback: &str) -> String {
if exists {
requested.to_string()
} else {
fallback.to_string()
}
}
fn read_json_file<T: for<'de> Deserialize<'de>>(path: &str) -> Result<T, String> {
let file = PROMPTS_DIR
.get_file(path)
.ok_or_else(|| format!("Prompt config file not found: {}", path))?;
let content = file
.contents_utf8()
.ok_or_else(|| format!("Prompt config file is not valid UTF-8: {}", path))?;
serde_json::from_str(content)
.map_err(|error| format!("Failed to parse prompt config {}: {}", path, error))
}

53
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "TalkFlow",
"version": "0.1.0",
"identifier": "com.trixter.talkflow",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "widget",
"title": "TalkFlow",
"url": "http://localhost:1420",
"width": 56,
"height": 56,
"minWidth": 52,
"minHeight": 52,
"resizable": true,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"acceptFirstMouse": true,
"shadow": false,
"x": 682,
"y": 800
}
],
"macOSPrivateApi": true,
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": "entitlements.plist",
"infoPlist": "Info.plist"
}
}
}

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,227 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Mic, Keyboard, Check, AlertCircle } from "lucide-react";
import {
PermissionStatus,
checkAccessibilityPermission,
checkMicrophonePermission,
requestMicrophonePermission,
} from "../lib/permissions";
import { logError } from "../lib/logger";
interface PermissionRowProps {
icon: React.ReactNode;
title: string;
description: string;
status: PermissionStatus;
onAction: () => void;
helpText?: string;
}
function PermissionRow({ icon, title, description, status, onAction, helpText }: PermissionRowProps) {
const isGranted = status === "granted";
const isDenied = status === "denied";
const isPrompting = status === "prompting";
const statusLabel = isGranted ? "Готово" : isPrompting ? "Проверьте" : isDenied ? "Нужно действие" : "Не выдано";
return (
<div
className="card"
style={{
padding: 20,
display: "flex",
alignItems: "flex-start",
gap: 16,
background: isGranted ? "rgba(255,255,255,0.88)" : "var(--surface)",
}}
>
<div
style={{
width: 42,
height: 42,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: isGranted ? "#000" : "rgba(255,255,255,0.7)",
color: isGranted ? "#fff" : "var(--text-mid)",
flexShrink: 0,
}}
>
{isGranted ? <Check size={18} strokeWidth={2.5} /> : icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, marginBottom: 8 }}>
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 2 }}>{title}</div>
<div className="label">{statusLabel}</div>
</div>
{!isGranted && (
<button onClick={onAction} className={isPrompting ? "btn" : "btn btn-primary"} style={{ minWidth: 124 }}>
{isPrompting ? "Проверить" : isDenied ? "Повторить" : "Разрешить"}
</button>
)}
</div>
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>{description}</div>
{helpText && <div style={{ marginTop: 8, fontSize: 12, color: "var(--text-low)", lineHeight: 1.55 }}>{helpText}</div>}
</div>
</div>
);
}
interface PermissionScreenProps {
onComplete: () => void;
}
export function PermissionScreen({ onComplete }: PermissionScreenProps) {
const [micStatus, setMicStatus] = useState<PermissionStatus>("unknown");
const [accStatus, setAccStatus] = useState<PermissionStatus>("unknown");
useEffect(() => {
checkMicrophonePermission().then(setMicStatus);
checkAccessibilityPermission().then(setAccStatus);
}, []);
const handleMicRequest = async () => {
setMicStatus("prompting");
const granted = await requestMicrophonePermission();
setMicStatus(granted ? "granted" : "denied");
};
const handleAccessibilityRequest = async () => {
if (accStatus === "prompting") {
const nextAccStatus = await checkAccessibilityPermission();
setAccStatus(nextAccStatus);
return;
}
try {
await invoke("open_accessibility_settings");
setAccStatus("prompting");
} catch (e) {
void logError("PERMISSIONS", `Failed to open accessibility settings: ${e instanceof Error ? e.message : String(e)}`);
setAccStatus("denied");
}
};
const handleContinue = async () => {
const [nextMicStatus, nextAccStatus] = await Promise.all([
checkMicrophonePermission(),
checkAccessibilityPermission(),
]);
setMicStatus(nextMicStatus);
setAccStatus(nextAccStatus);
if (nextMicStatus !== "granted" || nextAccStatus !== "granted") {
return;
}
await invoke("widget_resize", { width: 44, height: 44 });
onComplete();
};
const canContinue = micStatus === "granted" && accStatus === "granted";
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(244, 241, 235, 0.72)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
zIndex: 9999,
padding: 24,
}}
>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
className="card"
style={{
width: "min(100%, 760px)",
padding: 28,
display: "flex",
flexDirection: "column",
gap: 22,
boxShadow: "var(--shadow-panel)",
}}
>
<div style={{ display: "grid", gap: 10 }}>
<div className="label kicker">System Access</div>
<h1 className="headline-accent" style={{ fontSize: 40, lineHeight: 0.96, margin: 0, fontWeight: 700 }}>
Доступы для TalkFlow
</h1>
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, color: "var(--text-mid)", lineHeight: 1.7 }}>
Интерфейс уже готов. Осталось выдать системные разрешения для записи с микрофона и работы глобальной горячей клавиши.
</p>
</div>
<div style={{ display: "grid", gap: 14 }}>
<PermissionRow
icon={<Mic size={18} strokeWidth={1.75} />}
title="Микрофон"
description="Нужен для записи голоса перед отправкой на распознавание."
status={micStatus}
onAction={handleMicRequest}
/>
<PermissionRow
icon={<Keyboard size={18} strokeWidth={1.75} />}
title="Универсальный доступ"
description="Нужен для глобальной горячей клавиши и вставки текста в активное приложение."
status={accStatus}
onAction={handleAccessibilityRequest}
helpText="Откроются системные настройки macOS. После выдачи доступа вернитесь сюда и нажмите «Продолжить»."
/>
</div>
{(accStatus === "prompting" || micStatus === "denied") && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "14px 16px",
borderRadius: 18,
border: "1px solid rgba(0,0,0,0.08)",
background: "rgba(255,255,255,0.56)",
}}
>
<AlertCircle size={15} style={{ color: "var(--text-low)", flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: 12, color: "var(--text-mid)", lineHeight: 1.6 }}>
{micStatus === "denied"
? "Если микрофон был отклонен ранее, откройте Системные настройки -> Конфиденциальность и безопасность -> Микрофон и включите TalkFlow вручную."
: "macOS применяет доступ к универсальному доступу не мгновенно. После изменения системной настройки просто вернитесь в приложение и продолжите."}
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 18, paddingTop: 6 }}>
<div style={{ fontSize: 12, color: canContinue ? "var(--success)" : "var(--text-low)", lineHeight: 1.55 }}>
{canContinue ? "Все доступы выданы." : "Продолжение станет доступно после проверки обоих разрешений."}
</div>
<button
onClick={handleContinue}
className={canContinue ? "btn btn-primary" : "btn"}
style={{ minWidth: 160 }}
>
{canContinue ? "Продолжить" : "Проверить доступы"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,71 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
function TrafficLight({ color, onClick, title }: { color: string; onClick: () => void; title: string }) {
return (
<button
title={title}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
width: 12,
height: 12,
borderRadius: 999,
background: color,
border: "none",
padding: 0,
cursor: "pointer",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.12)",
}}
/>
);
}
export function TitleBar() {
const win = getCurrentWindow();
return (
<div
style={{
display: "flex",
alignItems: "stretch",
height: 48,
background: "rgba(250, 249, 246, 0.96)",
borderBottom: "1px solid rgba(0, 0, 0, 0.05)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
userSelect: "none",
flexShrink: 0,
position: "relative",
zIndex: 2,
}}
>
<div
data-tauri-drag-region
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "0 14px 0 14px",
cursor: "default",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, width: 56 }}>
<TrafficLight color="#ff5f57" title="Закрыть" onClick={() => win.close()} />
<TrafficLight color="#febc2e" title="Свернуть" onClick={() => win.minimize()} />
<TrafficLight color="#28c840" title="Развернуть" onClick={() => win.toggleMaximize()} />
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: "rgba(0,0,0,0.72)", letterSpacing: "-0.02em" }}>
TalkFlow
</div>
<div style={{ width: 56 }} />
</div>
</div>
);
}

128
src/components/Waveform.tsx Normal file
View file

@ -0,0 +1,128 @@
import { useEffect, useRef } from "react";
interface WaveformProps {
stream: MediaStream | null;
isActive: boolean;
}
export function Waveform({ stream, isActive }: WaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animRef = useRef<number>(0);
const levelRef = useRef(0);
useEffect(() => {
if (!stream || !isActive) {
cancelAnimationFrame(animRef.current);
drawEmpty();
return;
}
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.88;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.fftSize);
const draw = () => {
animRef.current = requestAnimationFrame(draw);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
// Use actual display dimensions
const dpr = window.devicePixelRatio || 1;
const displayWidth = canvas.clientWidth;
const displayHeight = canvas.clientHeight;
if (canvas.width !== displayWidth * dpr || canvas.height !== displayHeight * dpr) {
canvas.width = displayWidth * dpr;
canvas.height = displayHeight * dpr;
ctx.scale(dpr, dpr);
}
analyser.getByteTimeDomainData(dataArray);
ctx.clearRect(0, 0, displayWidth, displayHeight);
let sumSquares = 0;
for (let i = 0; i < dataArray.length; i++) {
const normalized = (dataArray[i] - 128) / 128;
sumSquares += normalized * normalized;
}
const rms = Math.sqrt(sumSquares / dataArray.length);
const boostedLevel = Math.pow(Math.min(1, rms * 8.5), 0.55);
const quietFloor = rms > 0.006 ? 0.12 : 0;
const levelTarget = Math.max(quietFloor, boostedLevel);
levelRef.current = levelRef.current * 0.7 + levelTarget * 0.3;
const time = performance.now() / 340;
const centerY = displayHeight / 2;
const baseAmplitude = 1.2 + levelRef.current * displayHeight * 0.34;
const lineConfigs = [
{ amplitude: 1.28, speed: 1, phase: 0, alpha: 0.94, width: 2.4 },
{ amplitude: 1.08, speed: 1.16, phase: Math.PI / 5.2, alpha: 0.58, width: 1.9 },
{ amplitude: 0.92, speed: 1.32, phase: Math.PI / 2.9, alpha: 0.42, width: 1.55 },
{ amplitude: 0.78, speed: 0.86, phase: Math.PI / 1.9, alpha: 0.3, width: 1.3 },
{ amplitude: 0.66, speed: 1.54, phase: Math.PI / 1.35, alpha: 0.22, width: 1.1 },
{ amplitude: 0.56, speed: 0.68, phase: Math.PI / 1.08, alpha: 0.16, width: 1 },
];
ctx.lineCap = "round";
ctx.lineJoin = "round";
lineConfigs.forEach((line) => {
ctx.beginPath();
for (let x = 0; x <= displayWidth; x += 2) {
const progress = x / displayWidth;
const envelope = Math.sin(progress * Math.PI);
const primary = Math.sin(progress * Math.PI * 2.1 + time * line.speed + line.phase);
const secondary = Math.sin(progress * Math.PI * 4.2 - time * (line.speed * 1.15) + line.phase * 0.65);
const tertiary = Math.cos(progress * Math.PI * 6.4 + time * 0.8 + line.phase);
const y = centerY + ((primary * 0.72) + (secondary * 0.2) + (tertiary * 0.08)) * baseAmplitude * line.amplitude * envelope;
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.strokeStyle = `rgba(0, 0, 0, ${line.alpha})`;
ctx.lineWidth = line.width;
ctx.shadowBlur = line.alpha > 0.85 ? 12 : line.alpha > 0.5 ? 6 : 0;
ctx.shadowColor = "rgba(0, 0, 0, 0.12)";
ctx.stroke();
});
ctx.shadowBlur = 0;
};
draw();
return () => {
cancelAnimationFrame(animRef.current);
source.disconnect();
audioCtx.close();
levelRef.current = 0;
};
}, [stream, isActive]);
function drawEmpty() {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
return (
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}

View file

@ -0,0 +1,29 @@
{
"assistantName": "TalkFlow",
"task": "Lightly clean the transcription while preserving the speaker's exact wording as much as possible.",
"coreRules": [
"Preserve the speaker's words and sentence structure as literally as possible.",
"Never change the author's intent, argument structure, or factual content.",
"Never translate. Output must stay in the same language as the input.",
"Never invent content, add explanations, expand abbreviations, or finish the speaker's thought for them.",
"Remove filler words only when they are unmistakably filler ({filler_examples}). If there is doubt, keep the word."
],
"whatToFix": [
"Fix obvious speech-recognition mistakes and typos.",
"Remove stutters and accidental immediate repetitions only when they are clearly accidental or explicitly covered by the active style rules.",
"Fix punctuation and capitalization conservatively."
],
"whatNotToDo": [
"Do not remove meaningful trailing words such as 'also', 'too', 'yet', 'также', 'тоже', 'еще' just because the phrase feels incomplete.",
"Do not rewrite a fragment into a more polished or finished sentence unless the active style explicitly allows a very small smoothing step.",
"Do not compress, summarize, or paraphrase.",
"If the input is fragmentary, preserve the fragmentary form.",
"In technical context, do not turn paths, commands, identifiers, imports, URLs, or file names into plain natural-language text."
],
"outputRules": [
"Return only the cleaned text.",
"Do not add commentary or explanations.",
"If the input is a single short phrase, return a single short phrase.",
"When unsure, prefer keeping the original words over editing them."
]
}

View file

@ -0,0 +1,8 @@
{
"displayName": "the detected language",
"fillerExamples": ["um", "uh", "like"],
"rules": [
"Preserve colloquial speech when it appears meaningful, personal, or expressive rather than accidental."
],
"examples": []
}

View file

@ -0,0 +1,8 @@
{
"displayName": "English",
"fillerExamples": ["um", "uh", "like", "you know"],
"rules": [
"Preserve short trailing qualifiers and colloquial speech if they may carry meaning in context."
],
"examples": []
}

View file

@ -0,0 +1,24 @@
{
"displayName": "Russian",
"fillerExamples": ["um", "uh", "like", "ну", "э", "короче", "вот", "типа"],
"rules": [
"Preserve meaningful Russian particles, discourse markers, and short service words unless they are unmistakably filler.",
"Treat spoken rhythm, colloquial phrasing, and mild slang as part of the user's voice, not as something to automatically normalize away.",
"When a word begins with a repeated leading sound plus a hyphen, such as 'П-практичность', treat it as a possible expressive hesitation or speech feature, not automatically as an error. Defer to the active style before smoothing it.",
"Fix obvious recognition slips in common Russian words when the intended word is clear from context, for example 'севодня' -> 'сегодня'."
],
"examples": [
{
"raw": "На странице настроек также",
"cleaned": "На странице настроек также"
},
{
"raw": "Севодня открой настройки",
"cleaned": "Сегодня открой настройки"
},
{
"raw": "П-практичность",
"cleaned": "П-практичность"
}
]
}

View file

@ -0,0 +1,34 @@
{
"version": 2,
"defaultLanguage": "default",
"defaultStyle": "classic",
"baseFile": "base/common.json",
"styleOrder": ["classic", "business", "tech"],
"languages": {
"default": { "file": "languages/default.json" },
"ru": { "file": "languages/ru.json" },
"en": { "file": "languages/en.json" }
},
"styles": {
"classic": {
"file": "styles/classic.json",
"uiTitle": "Классический",
"uiDescription": "Исправляются ошибки, расставляются знаки пунктуации, удаляется словесный мусор. Текст остается максимально близким к оригиналу."
},
"business": {
"file": "styles/business.json",
"uiTitle": "Деловой",
"uiDescription": "Речь становится чище и формальнее, а явные речевые запинки сглаживаются. Подходит для писем, задач и рабочих переписок."
},
"tech": {
"file": "styles/tech.json",
"uiTitle": "Разработка",
"uiDescription": "Особый упор на терминологию, code-aware обработку и IT-контекст. Пути, команды и имена файлов сохраняются в техническом виде."
}
},
"overrides": {
"ru:classic": "overrides/ru.classic.json",
"ru:business": "overrides/ru.business.json",
"ru:tech": "overrides/ru.tech.json"
}
}

View file

@ -0,0 +1,13 @@
{
"rules": [
"In Russian business mode, smooth obvious leading hesitation artifacts when they reduce readability in a professional message.",
"If a form such as 'П-практичность' is clearly just a speech hesitation and not meaningful emphasis, normalize it to 'Практичность'.",
"Keep the word itself and its meaning; only smooth the hesitation artifact."
],
"examples": [
{
"raw": "П-практичность очень важна",
"cleaned": "Практичность очень важна"
}
]
}

View file

@ -0,0 +1,12 @@
{
"rules": [
"In Russian classic mode, preserve expressive leading stutter or hesitation forms such as 'П-практичность' when they look intentional or characteristic of the speaker's delivery.",
"Do not smooth hyphenated leading-sound repetitions in Russian unless they are clearly accidental recognition noise."
],
"examples": [
{
"raw": "П-практичность",
"cleaned": "П-практичность"
}
]
}

View file

@ -0,0 +1,15 @@
{
"rules": [
"Expect Russian developer speech with English technical terms embedded into Russian grammar, for example: 'сдеплоить', 'запушить', 'заасайнить'.",
"In code, path, import, command, URL, package, or file-name context, convert spoken symbols into literal symbols when appropriate: 'слеш' -> '/', 'обратный слеш' -> '\\', 'точка' -> '.', 'двоеточие' -> ':', 'запятая' -> ',', 'дефис' or 'минус' -> '-', 'нижнее подчеркивание' -> '_', 'открывающая скобка' -> '(', 'закрывающая скобка' -> ')'.",
"Treat words such as 'src', 'api', 'tauri', 'import', 'const', 'function', 'cargo', 'bun', 'npm', 'git', package names, and file extensions as strong evidence of technical context.",
"Never replace one spoken symbol with another in technical context. If the speaker said 'слеш', do not turn it into a dot.",
"In Russian tech mode, preserve expressive leading stutter forms unless smoothing them is necessary to correctly reconstruct a code token or file name."
],
"examples": [
{
"raw": "сделал cleanup заметно бережнее в src slash tauri slash src slash ai dot rs",
"cleaned": "Сделал cleanup заметно бережнее в src-tauri/src/ai.rs."
}
]
}

View file

@ -0,0 +1,9 @@
{
"promptTitle": "BUSINESS STYLE",
"rules": [
"Make the text cleaner and more professional, but keep the original meaning and structure.",
"You may apply small readability-focused smoothing when the speaker's wording contains obvious hesitation artifacts that would look awkward in a business message.",
"Do not remove substantive words or complete unfinished thoughts on the speaker's behalf."
],
"examples": []
}

View file

@ -0,0 +1,9 @@
{
"promptTitle": "CLASSIC STYLE",
"rules": [
"Preserve the speaker's wording as literally as possible.",
"Do not rewrite for style, do not shorten fragments, and do not remove content words unless they are unmistakable filler.",
"Prefer preserving expressive disfluencies over smoothing them away when they may be part of the speaker's manner of speaking."
],
"examples": []
}

View file

@ -0,0 +1,16 @@
{
"promptTitle": "TECH & DEV STYLE",
"rules": [
"The speaker is likely a software developer. Expect technical jargon, code fragments, package names, and English terms mixed with native grammar.",
"Detect code context aggressively. If the input appears to contain a file path, file name, import, command, URL, extension, identifier, env var, stack trace, or code fragment, preserve or reconstruct the technical structure instead of rewriting it as plain prose.",
"Fix obvious misrecognitions of English technical terms to their correct spelling or accepted industry equivalent.",
"Format code elements, variable names, file names, package names, and commands properly if they appear in speech.",
"Preserve all technical context and wording as much as possible."
],
"examples": [
{
"raw": "src slash tauri slash src slash ai dot rs",
"cleaned": "src-tauri/src/ai.rs"
}
]
}

352
src/index.css Normal file
View file

@ -0,0 +1,352 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Manrope:wght@600;700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap");
@import "tailwindcss";
:root {
--bg: #faf9f6;
--bg-soft: #f4f1eb;
--surface: rgba(255, 255, 255, 0.72);
--surface-solid: #f8f5ef;
--surface-strong: #f1ede6;
--surface-inverse: #000000;
--border: rgba(0, 0, 0, 0.09);
--border-strong: rgba(0, 0, 0, 0.16);
--text-hi: #000000;
--text-mid: #39342d;
--text-low: #5d564d;
--text-faint: #847d73;
--accent: #000000;
--accent-contrast: #ffffff;
--danger: #8f2d20;
--danger-soft: rgba(143, 45, 32, 0.08);
--success: #1f5130;
--success-soft: rgba(31, 81, 48, 0.08);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-app: 16px;
--radius-pill: 999px;
--radius-bubble: 22px 22px 0 22px;
--font: "Inter", system-ui, sans-serif;
--font-accent: "Playfair Display", Georgia, serif;
--font-brand: "Manrope", "Inter", system-ui, sans-serif;
--shadow-soft: 0 8px 20px rgba(0, 0, 0, 0.04);
--shadow-panel: 0 16px 40px rgba(0, 0, 0, 0.08);
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
}
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: transparent;
}
body {
margin: 0;
font-family: var(--font);
font-size: 14px;
line-height: 1.6;
color: var(--text-hi);
background: transparent;
}
button,
input,
textarea,
select {
font: inherit;
}
.app-root {
width: 100vw;
height: 100vh;
color: var(--text-hi);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.8), transparent 30%),
radial-gradient(circle at right 20%, rgba(0, 0, 0, 0.035), transparent 24%),
linear-gradient(180deg, #fcfbf8 0%, var(--bg) 42%, #f5f1ea 100%);
border-radius: var(--radius-app);
overflow: hidden;
position: relative;
isolation: isolate;
}
.app-root::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 0, 0, 0.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.018) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(circle at center, black 30%, transparent 85%);
pointer-events: none;
opacity: 0.35;
border-radius: inherit;
}
.surface,
.surface-hi,
.card {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.surface {
border-radius: var(--radius-md);
}
.surface-hi {
background: rgba(255, 255, 255, 0.88);
border-color: var(--border-strong);
border-radius: var(--radius-md);
}
.card {
border-radius: var(--radius-lg);
padding: 18px;
box-shadow: none;
}
.label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-low);
}
.kicker {
display: inline-flex;
align-items: center;
gap: 10px;
}
.kicker::before {
content: "";
width: 28px;
height: 1px;
background: rgba(0, 0, 0, 0.22);
}
.headline-accent {
font-family: var(--font-accent);
letter-spacing: -0.05em;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
border-radius: var(--radius-pill);
cursor: pointer;
transition:
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease,
transform 0.18s var(--ease-standard),
box-shadow 0.18s ease;
background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--border);
color: var(--text-hi);
}
.btn:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.94);
border-color: var(--border-strong);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background: var(--surface-inverse);
border-color: var(--surface-inverse);
color: var(--accent-contrast);
}
.btn-primary:hover {
background: #171717;
border-color: #171717;
}
.btn-danger {
background: var(--danger-soft);
border-color: rgba(143, 45, 32, 0.18);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(143, 45, 32, 0.12);
border-color: rgba(143, 45, 32, 0.28);
}
.input {
width: 100%;
min-height: 46px;
padding: 0 0 10px;
font-size: 13px;
font-family: var(--font);
color: var(--text-hi);
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
background: transparent;
outline: none;
transition: border-color 0.18s ease;
}
.input:focus {
border-bottom-color: var(--text-hi);
}
.input::placeholder {
color: var(--text-faint);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
letter-spacing: -0.01em;
cursor: pointer;
transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease, transform 0.14s ease;
color: var(--text-mid);
border: 1px solid transparent;
user-select: none;
background: transparent;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.55);
color: var(--text-hi);
}
.nav-item.active {
background: #000;
border-color: #000;
color: #fff;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.b-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.b-table th {
text-align: left;
padding: 10px 16px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-low);
border-bottom: 1px solid var(--border);
}
.b-table td {
padding: 11px 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
color: var(--text-mid);
}
.b-table tr:hover td {
background: rgba(255, 255, 255, 0.44);
}
.bubble-black {
background: #000;
color: #fff;
border-radius: var(--radius-bubble);
}
.section-title-ui {
margin: 0;
font-size: 28px;
line-height: 1;
letter-spacing: -0.04em;
font-weight: 700;
}
.section-subtle {
margin: 0;
font-size: 13px;
color: var(--text-mid);
line-height: 1.6;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.14);
border-radius: 999px;
}
@keyframes slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes widget-pill-morph-in {
from {
opacity: 0.82;
transform: scaleX(0.72) scaleY(0.82) translateY(3px);
}
to {
opacity: 1;
transform: scaleX(1) scaleY(1) translateY(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes voice-logo-pulse {
0%, 100% { transform: scaleY(1); opacity: 1; }
50% { transform: scaleY(0.58); opacity: 0.7; }
}
@keyframes widget-processing-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.38;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}

2
src/lib/hotkeyEvents.ts Normal file
View file

@ -0,0 +1,2 @@
export const SETTINGS_UPDATED_EVENT = "settings-updated";
export const HISTORY_UPDATED_EVENT = "history-updated";

44
src/lib/logger.ts Normal file
View file

@ -0,0 +1,44 @@
import { invoke } from "@tauri-apps/api/core";
export async function logInfo(tag: string, message: string): Promise<void> {
console.log(`[${tag}] ${message}`);
try {
await invoke("log_event", { level: "INFO", tag, message });
} catch {
// ignore Tauri errors
}
}
export async function logError(tag: string, message: string): Promise<void> {
console.error(`[${tag}] ${message}`);
try {
await invoke("log_event", { level: "ERROR", tag, message });
} catch {
// ignore Tauri errors
}
}
export async function logDebug(tag: string, message: string): Promise<void> {
console.log(`[${tag}] ${message}`);
try {
await invoke("log_event", { level: "DEBUG", tag, message });
} catch {
// ignore Tauri errors
}
}
export async function getLogPath(): Promise<string> {
try {
return await invoke("get_log_path_cmd");
} catch {
return "~/.talkflow/talkflow.log";
}
}
export async function clearLogs(): Promise<void> {
try {
await invoke("clear_logs");
} catch {
// ignore
}
}

44
src/lib/permissions.ts Normal file
View file

@ -0,0 +1,44 @@
import { invoke } from "@tauri-apps/api/core";
export type PermissionStatus = 'unknown' | 'granted' | 'denied' | 'prompting';
export interface PermissionsState {
microphone: PermissionStatus;
accessibility: PermissionStatus;
}
export async function checkMicrophonePermission(): Promise<PermissionStatus> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
return 'granted';
} catch (e) {
return 'denied';
}
}
export async function requestMicrophonePermission(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
return true;
} catch {
return false;
}
}
export async function checkAccessibilityPermission(): Promise<PermissionStatus> {
try {
const trusted = await invoke<boolean>("check_accessibility_permission");
return trusted ? "granted" : "denied";
} catch {
return "unknown";
}
}
export async function checkAllPermissions(): Promise<PermissionsState> {
return {
microphone: await checkMicrophonePermission(),
accessibility: await checkAccessibilityPermission(),
};
}

192
src/lib/store.ts Normal file
View file

@ -0,0 +1,192 @@
import { load } from "@tauri-apps/plugin-store";
export interface HistoryEntry {
id: string;
timestamp: string;
duration: number;
raw: string;
cleaned: string;
}
export interface AppSettings {
apiKey: string;
language: string;
doubleTapTimeout: number;
style: "classic" | "business" | "tech";
micId: string;
/** Custom Whisper-compatible endpoint URL (leave empty for OpenAI) */
whisperEndpoint: string;
/** Custom LLM endpoint URL (leave empty for OpenAI) */
llmEndpoint: string;
/** If true, user provides their own API key. If false, uses subscription */
useOwnKey: boolean;
}
const MODIFIERS = ["Ctrl", "Alt", "Option", "Shift", "Command", "Cmd", "Meta", "Control"];
export const DEFAULT_HOTKEY = "Ctrl+Alt+Space";
function isMacPlatform(): boolean {
if (typeof navigator === "undefined") return false;
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
}
export function formatHotkeyLabel(hotkey: string): string {
const parts = hotkey.split("+").map((part) => part.trim());
const isMac = isMacPlatform();
const formatted = parts.map((part) => {
const lower = part.toLowerCase();
if (lower === "ctrl" || lower === "control") {
return isMac ? "Control" : "Ctrl";
}
if (lower === "alt" || lower === "option") {
return isMac ? "Option" : "Alt";
}
if (lower === "cmd" || lower === "command" || lower === "meta") {
return isMac ? "Command" : "Cmd";
}
return part;
});
return formatted.join(" + ");
}
export function validateHotkey(hotkey: string): { valid: boolean; error?: string } {
const parts = hotkey.split("+").map(p => p.trim());
const modifiers = parts.filter(p => MODIFIERS.includes(p));
const mainKeys = parts.filter(p => !MODIFIERS.includes(p));
if (parts.length < 2) {
return {
valid: false,
error: "Минимум 2 клавиши: модификатор + основная клавиша"
};
}
if (mainKeys.length === 0) {
return {
valid: false,
error: "Добавьте основную клавишу (Space, A, F1 и т.д.)"
};
}
if (mainKeys.length > 1) {
return {
valid: false,
error: "Только одна основная клавиша"
};
}
if (modifiers.length === 0) {
return {
valid: false,
error: "Добавьте хотя бы один модификатор: Ctrl, Alt/Option, Shift или Cmd"
};
}
return { valid: true };
}
const DEFAULT_SETTINGS: AppSettings = {
apiKey: "",
language: "ru",
doubleTapTimeout: 400,
style: "classic",
micId: "",
whisperEndpoint: "",
llmEndpoint: "",
useOwnKey: true,
};
function parseStyle(value: unknown): AppSettings["style"] | undefined {
if (value === "classic" || value === "business" || value === "tech") {
return value;
}
return undefined;
}
function normalizeSavedSettings(saved: unknown): Partial<AppSettings> {
if (!saved || typeof saved !== "object") {
return {};
}
const raw = saved as Record<string, unknown>;
return {
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : undefined,
language: typeof raw.language === "string" ? raw.language : undefined,
doubleTapTimeout: typeof raw.doubleTapTimeout === "number" ? raw.doubleTapTimeout : undefined,
style: parseStyle(raw.style),
micId: typeof raw.micId === "string" ? raw.micId : undefined,
whisperEndpoint: typeof raw.whisperEndpoint === "string" ? raw.whisperEndpoint : undefined,
llmEndpoint: typeof raw.llmEndpoint === "string" ? raw.llmEndpoint : undefined,
useOwnKey: typeof raw.useOwnKey === "boolean" ? raw.useOwnKey : undefined,
};
}
let _store: Awaited<ReturnType<typeof load>> | null = null;
async function getStore() {
if (!_store) {
_store = await load("talkflow.json");
}
return _store;
}
export async function getSettings(): Promise<AppSettings> {
const store = await getStore();
const saved = await store.get<unknown>("settings");
const result = { ...DEFAULT_SETTINGS, ...normalizeSavedSettings(saved) };
return result;
}
export async function saveSettings(settings: Partial<AppSettings>): Promise<void> {
const store = await getStore();
const current = await getSettings();
await store.set("settings", { ...current, ...settings });
await store.save();
}
export async function getHistory(): Promise<HistoryEntry[]> {
const store = await getStore();
return (await store.get<HistoryEntry[]>("history")) || [];
}
export async function addHistoryEntry(entry: HistoryEntry): Promise<void> {
const store = await getStore();
const history = await getHistory();
const updated = [entry, ...history].slice(0, 500); // max 500 entries
await store.set("history", updated);
await store.save();
}
export async function deleteHistoryEntry(id: string): Promise<void> {
const store = await getStore();
const history = await getHistory();
await store.set("history", history.filter((e) => e.id !== id));
await store.save();
}
export async function clearHistory(): Promise<void> {
const store = await getStore();
await store.set("history", []);
await store.save();
}
const PERMISSIONS_PASSED_KEY = "permissions_passed";
export async function getPermissionsPassed(): Promise<boolean> {
const store = await getStore();
return (await store.get<boolean>(PERMISSIONS_PASSED_KEY)) ?? false;
}
export async function setPermissionsPassed(value: boolean): Promise<void> {
const store = await getStore();
await store.set(PERMISSIONS_PASSED_KEY, value);
await store.save();
}

Some files were not shown because too many files have changed in this diff Show more