Initial Thisper MVP
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
src-tauri/target
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
6
.license-checker.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"onlyAllow": "MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense",
|
||||
"excludePrivatePackages": true,
|
||||
"production": true,
|
||||
"summary": true
|
||||
}
|
||||
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
52
LEGACY_HARDWARE_VALIDATION.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Legacy Hardware Validation
|
||||
|
||||
This report exists to close the remaining Phase 2 validation work against the target profile: older desktop hardware with limited RAM where Thisper must remain responsive and low-overhead.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
### Background Stability
|
||||
|
||||
- Scenario: Leave Thisper running in the tray/background for an extended period.
|
||||
- Acceptance target: No crash, no hotkey loss, no visible UI freeze when restoring the window.
|
||||
- Current evidence: User-reported runtime of more than 20 hours without API or stability failures.
|
||||
- Status: `partially validated`
|
||||
|
||||
### Repeated Cross-App Rewrite
|
||||
|
||||
- Scenario: Use `Ctrl + Alt + R` repeatedly across plain-text targets such as Notepad and browser text boxes.
|
||||
- Acceptance target: No destructive replacement on provider failure, no clipboard corruption after failures, stable repeated use without restart.
|
||||
- Current evidence: Informal real-world usage is positive, but no structured pass is recorded yet.
|
||||
- Status: `remaining`
|
||||
|
||||
### Long-Form Desktop Rewrite
|
||||
|
||||
- Scenario: Rewrite a multi-paragraph block through the main window and inspect diff/output toggles.
|
||||
- Acceptance target: UI remains responsive during rewrite and the diff view reflects only actual edits.
|
||||
- Current evidence: Main UI build path is working and has been used successfully, but no formal timing pass is logged yet.
|
||||
- Status: `remaining`
|
||||
|
||||
### Successive Rewrites Without Restart
|
||||
|
||||
- Scenario: Perform multiple rewrites in sequence from both the main UI and the global shortcut flow.
|
||||
- Acceptance target: No cumulative instability, no stale model state, no stuck loading state.
|
||||
- Current evidence: Runtime observability is now present to support this pass, but the pass itself is still pending.
|
||||
- Status: `remaining`
|
||||
|
||||
### Resource Footprint
|
||||
|
||||
- Scenario: Observe memory use and responsiveness on constrained hardware during idle, active rewrite, and tray-hidden states.
|
||||
- Acceptance target: No runaway memory growth and acceptable perceived latency for short text.
|
||||
- Current evidence: Build/runtime artifacts are large in development, but that is mostly `target/`; release footprint still needs explicit validation.
|
||||
- Status: `remaining`
|
||||
|
||||
## What To Record During The Pass
|
||||
|
||||
- idle memory use
|
||||
- active rewrite latency for short and medium inputs
|
||||
- whether the tray/hotkey path remains responsive after long idle time
|
||||
- whether clipboard restoration remains correct after both success and failure
|
||||
- any target app classes that consistently fail or behave inconsistently
|
||||
|
||||
## Completion Rule
|
||||
|
||||
Phase 2 desktop validation is complete when each scenario above has an explicit observed result recorded here, including failures or caveats.
|
||||
71
PHASE3_SPEECH_PLAN.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Phase 3 Speech Plan
|
||||
|
||||
Speech is queued after Phase 2. It is not part of current desktop completion criteria.
|
||||
|
||||
## Scope
|
||||
|
||||
- microphone capture
|
||||
- chunked or streaming transcription
|
||||
- pass transcript through the same rewrite modes used for typed input
|
||||
- preserve the same trust rules as typed workflows
|
||||
- reuse the existing review and diff layer where practical
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Phase 2 desktop/system utility work marked complete
|
||||
- `THISPER_STATUS.md` updated
|
||||
- tray/background behavior stable
|
||||
- legacy hardware validation report updated
|
||||
- documentation aligned with actual behavior
|
||||
|
||||
## Required Interfaces
|
||||
|
||||
Add a dedicated transcription boundary instead of mixing speech into the rewrite provider:
|
||||
|
||||
- `ITranscriptionProvider`
|
||||
- transcription request and response types
|
||||
- partial transcript event types
|
||||
- buffering rules for chunked and streaming modes
|
||||
|
||||
## Required Planning Outputs
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
- microphone capture lifecycle
|
||||
- mute/start/stop controls
|
||||
- audio buffering strategy
|
||||
- failure handling for permissions and device loss
|
||||
|
||||
### Transcript Pipeline
|
||||
|
||||
- partial transcript UX
|
||||
- final transcript handoff into rewrite modes
|
||||
- model/provider selection rules
|
||||
- retry and cancellation behavior
|
||||
|
||||
### Privacy Rules
|
||||
|
||||
- explicit handling of whether audio leaves the device
|
||||
- no silent cloud upload
|
||||
- no raw audio retention by default
|
||||
- no raw transcript persistence unless the user explicitly keeps it
|
||||
|
||||
### Acceptance Coverage
|
||||
|
||||
- real voice samples
|
||||
- noisy and clean environments
|
||||
- short dictation and long dictation
|
||||
- interruption/resume behavior
|
||||
- factual preservation and voice-preserving cleanup after transcription
|
||||
|
||||
## First Implementation Goal
|
||||
|
||||
Build a desktop speech path that feels like the typed workflow:
|
||||
|
||||
1. capture speech
|
||||
2. show transcript progressively
|
||||
3. run the existing rewrite modes
|
||||
4. review changes
|
||||
5. copy or accept output
|
||||
|
||||
Do not start with mobile speech. Keep the first speech implementation desktop-only.
|
||||
109
README.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Thisper
|
||||
|
||||
Thisper is a typing-first communication translator. It rewrites raw text into clearer text while staying as close as possible to the original meaning, voice, uncertainty, and tone.
|
||||
|
||||
## Current Desktop Scope
|
||||
|
||||
- Desktop text-to-text rewrite UI with diff review
|
||||
- Rewrite modes: `Preserve Voice`, `Clean`, `Readable`, `Formal`, `Concise`
|
||||
- Gemini-backed cloud rewrite provider with typed model selection
|
||||
- Secure Gemini API key storage through the system credential store
|
||||
- Global cross-app rewrite shortcut: `Ctrl + Alt + R`
|
||||
- System tray support with background operation
|
||||
- Non-destructive clipboard replacement flow: selected text is only replaced after the full rewrite succeeds
|
||||
- URL protection: links are treated as protected content and must survive rewrites unchanged
|
||||
- Local-only runtime observability for rewrite attempts, failures, model use, and last error category
|
||||
|
||||
## Desktop Workflow
|
||||
|
||||
### Main Window
|
||||
|
||||
1. Paste or type raw text.
|
||||
2. Choose a mode and model.
|
||||
3. Run `Rewrite`.
|
||||
4. Review either the final output or the diff.
|
||||
5. Copy the result.
|
||||
|
||||
### Cross-App Rewrite
|
||||
|
||||
1. Select plain text in another app.
|
||||
2. Press `Ctrl + Alt + R`.
|
||||
3. Thisper copies the selection, rewrites it in `Preserve Voice`, then pastes the full replacement only after the provider returns a complete result.
|
||||
4. The previous text clipboard contents are restored after the operation when they were available as plain text.
|
||||
|
||||
If no API key is configured, Thisper brings the main window forward instead of attempting a destructive rewrite.
|
||||
|
||||
## Tray And Background Behavior
|
||||
|
||||
- Thisper starts with a tray icon on desktop platforms where tray icons are supported.
|
||||
- Closing the window does not quit the app by default. The first close shows a tray hint, and later closes hide the window to the tray.
|
||||
- The tray menu provides:
|
||||
- `Show Thisper`
|
||||
- `Run Clipboard Rewrite`
|
||||
- `Quit`
|
||||
- The global shortcut remains active while the window is hidden.
|
||||
- `Quit` from the tray exits the app fully.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Cross-app rewrite is designed for plain-text targets first. Rich editors, browser text surfaces, and heavily customized inputs are best-effort.
|
||||
- Secure fields and some application surfaces may block selection, copy, paste, or synthetic key events.
|
||||
- Clipboard restoration currently preserves prior plain-text clipboard contents. Non-text clipboard formats are not yet restored.
|
||||
- If a rewrite attempts to drop a protected URL, Thisper now fails that rewrite instead of silently removing the link.
|
||||
- Model fidelity still depends on prompt behavior. High-value detail protection is planned, but not part of the current Phase 2 completion scope.
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Rust](https://rustup.rs/) stable toolchain
|
||||
- [Node.js](https://nodejs.org/) LTS
|
||||
- A Gemini API key from [Google AI Studio](https://aistudio.google.com/)
|
||||
|
||||
### Credentials
|
||||
|
||||
Use the in-app Settings dialog to save the Gemini API key into your system credential store.
|
||||
|
||||
For development, Thisper also accepts:
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_API_KEY = "YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
### Run Development
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## License Auditing
|
||||
|
||||
Thisper includes automated license-audit commands:
|
||||
|
||||
```powershell
|
||||
npm run licenses:npm
|
||||
npm run licenses:rust
|
||||
npm run licenses:all
|
||||
```
|
||||
|
||||
Current status:
|
||||
- automation is present
|
||||
- the final commercial allowlist policy is still pending
|
||||
- current Rust audit output includes weak-copyleft and platform-stack licenses that need an explicit policy decision before monetization
|
||||
|
||||
## Architecture
|
||||
|
||||
- Frontend: Vanilla TypeScript, HTML, CSS, Vite
|
||||
- Backend: Tauri v2, Rust
|
||||
- Provider boundary: pluggable rewrite provider trait
|
||||
- Current provider: Gemini
|
||||
- Desktop integration: global shortcut, clipboard pipeline, tray/background mode
|
||||
|
||||
## Project Tracking
|
||||
|
||||
See:
|
||||
|
||||
- `THISPER_STATUS.md` for current completion status
|
||||
- `LEGACY_HARDWARE_VALIDATION.md` for the Phase 2 validation checklist and report
|
||||
- `PHASE3_SPEECH_PLAN.md` for the queued next-phase speech scope
|
||||
58
THISPER_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Thisper Implementation Plan
|
||||
|
||||
This plan reflects the current repo state and the remaining work needed to close desktop Phase 2 before speech begins.
|
||||
|
||||
## Completed Baseline
|
||||
|
||||
- Desktop text-to-text MVP is complete.
|
||||
- The provider boundary exists and Gemini is integrated.
|
||||
- Secure credential storage is implemented.
|
||||
- Global shortcut and clipboard rewrite are implemented.
|
||||
- Streaming rewrite is implemented.
|
||||
- Tray and background lifecycle are implemented.
|
||||
- Runtime observability is implemented locally in-memory.
|
||||
|
||||
## Remaining To Close Phase 2
|
||||
|
||||
### 1. Validation
|
||||
|
||||
- Execute the legacy hardware validation checklist.
|
||||
- Record actual observed results in `LEGACY_HARDWARE_VALIDATION.md`.
|
||||
|
||||
### 2. Licensing Policy
|
||||
|
||||
- Keep audit automation in place.
|
||||
- Decide the commercial allowlist policy.
|
||||
- Update Rust audit configuration to match that policy.
|
||||
|
||||
## Current Desktop Contract
|
||||
|
||||
### Main UI
|
||||
|
||||
- typed input and output panes
|
||||
- explicit rewrite modes
|
||||
- diff review
|
||||
- copy output
|
||||
- secure settings for the Gemini API key
|
||||
|
||||
### Cross-App Utility
|
||||
|
||||
- `Ctrl + Alt + R` triggers selected-text rewrite
|
||||
- default global mode is `Preserve Voice`
|
||||
- selected text is only replaced after a complete rewrite succeeds
|
||||
- app can remain hidden in the tray while the hotkey stays active
|
||||
|
||||
### Tray Lifecycle
|
||||
|
||||
- closing the main window does not quit by default
|
||||
- first close shows a tray hint
|
||||
- later closes hide to tray
|
||||
- tray menu supports show, rewrite, and quit
|
||||
|
||||
## Queued Next
|
||||
|
||||
After Phase 2 is closed:
|
||||
|
||||
1. start the speech phase from `PHASE3_SPEECH_PLAN.md`
|
||||
2. queue trust refinements such as protected terms and high-value detail preservation
|
||||
3. revisit optimization work only after validation data exists
|
||||
109
THISPER_STATUS.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Thisper Status
|
||||
|
||||
This is the single source of truth for current delivery status.
|
||||
|
||||
## Completed
|
||||
|
||||
### Desktop MVP Foundation
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: The desktop app, main rewrite UI, mode selector, copy flow, and diff review are all implemented and working.
|
||||
- Acceptance condition: User can paste text, rewrite it, inspect diff, and copy output without leaving the app.
|
||||
- Repo pointer: `src/`, `index.html`, `src/styles.css`, `src-tauri/src/lib.rs`
|
||||
|
||||
### Provider Abstraction And Gemini Integration
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: Rewrite logic is abstracted behind a provider trait and Gemini is implemented as the current backend, including streaming.
|
||||
- Acceptance condition: Main UI and cross-app rewrite both run through the provider boundary instead of hard-coded prompt logic in the UI.
|
||||
- Repo pointer: `src-tauri/src/translator.rs`, `src-tauri/src/gemini.rs`, `src-tauri/src/lib.rs`
|
||||
|
||||
### Secure Credential Storage
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: The Gemini API key is stored in the system credential store rather than a plaintext app file.
|
||||
- Acceptance condition: Saving a key through Settings persists it securely across app restarts.
|
||||
- Repo pointer: `src-tauri/src/lib.rs`, `README.md`
|
||||
|
||||
### Global Shortcut And Clipboard Rewrite
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: `Ctrl + Alt + R` captures selected text, rewrites it in `Preserve Voice`, and pastes only after a full rewrite succeeds.
|
||||
- Acceptance condition: Cross-app rewrite works without deleting the original selection before the provider finishes.
|
||||
- Repo pointer: `src-tauri/src/lib.rs`, `README.md`
|
||||
|
||||
### Tray And Background Operation
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: Thisper now supports tray-based lifecycle management and continued hotkey operation while the window is hidden.
|
||||
- Acceptance condition: Closing the window hides to tray after the first hint, tray actions restore or quit correctly, and the hotkey remains active while hidden.
|
||||
- Repo pointer: `src-tauri/src/lib.rs`, `src/main.ts`, `index.html`, `src/styles.css`
|
||||
|
||||
### Runtime Observability
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: In-memory runtime metrics now track rewrite attempts, success/failure counts, last error, last model, and last rewrite duration.
|
||||
- Acceptance condition: Frontend can query and display runtime status without storing raw user text.
|
||||
- Repo pointer: `src-tauri/src/lib.rs`, `src/main.ts`
|
||||
|
||||
### License Audit Automation
|
||||
|
||||
- Status: `completed`
|
||||
- Rationale: npm and Rust license audit commands are wired into the repo.
|
||||
- Acceptance condition: `licenses:npm`, `licenses:rust`, and `licenses:all` are available and documented.
|
||||
- Repo pointer: `package.json`, `src-tauri/deny.toml`, `README.md`
|
||||
|
||||
## Remaining For Phase 2 Completion
|
||||
|
||||
### Legacy Hardware Validation Pass
|
||||
|
||||
- Status: `remaining`
|
||||
- Rationale: The implementation is in place, but the acceptance report still depends on a structured pass against the target hardware profile.
|
||||
- Acceptance condition: The validation checklist is executed and the report is updated with actual observed results.
|
||||
- Repo pointer: `LEGACY_HARDWARE_VALIDATION.md`
|
||||
|
||||
### Commercial License Allowlist Decision
|
||||
|
||||
- Status: `remaining`
|
||||
- Rationale: Audit automation exists, but the final commercial policy for weak-copyleft and platform-stack licenses is intentionally not locked yet.
|
||||
- Acceptance condition: A written allowlist decision is made and the Rust audit configuration is aligned with that decision.
|
||||
- Repo pointer: `src-tauri/deny.toml`, `README.md`
|
||||
|
||||
## Queued After Phase 2
|
||||
|
||||
### Speech Input Phase
|
||||
|
||||
- Status: `queued`
|
||||
- Rationale: Speech is the next planned phase after desktop Phase 2 is closed, but it is not part of current completion criteria.
|
||||
- Acceptance condition: Phase 2 is marked complete and the speech scope starts from the queued plan.
|
||||
- Repo pointer: `PHASE3_SPEECH_PLAN.md`
|
||||
|
||||
### High-Value Detail Protection
|
||||
|
||||
- Status: `queued`
|
||||
- Rationale: Protected terms, factual token preservation, and drift flags are important trust refinements, but they are not blocking current Phase 2 completion.
|
||||
- Acceptance condition: Add protected-term rules, token-preservation checks, and a stricter retry path without breaking current flows.
|
||||
- Repo pointer: `communication_translator_project_plan.md`
|
||||
|
||||
## Deferred / Explicitly Out Of Scope
|
||||
|
||||
### Mobile Platforms
|
||||
|
||||
- Status: `deferred`
|
||||
- Rationale: Android and iOS are not part of current Phase 2 desktop completion.
|
||||
- Acceptance condition: Revisit after speech planning and desktop validation.
|
||||
- Repo pointer: `PHASE3_SPEECH_PLAN.md`
|
||||
|
||||
### Local Models And Multi-Provider Routing
|
||||
|
||||
- Status: `deferred`
|
||||
- Rationale: The provider boundary exists, but local execution and multi-provider fallback are not required to close the current desktop scope.
|
||||
- Acceptance condition: Re-enter scope only after current desktop workflow is fully validated.
|
||||
- Repo pointer: `src-tauri/src/translator.rs`
|
||||
|
||||
### Major UI Redesign
|
||||
|
||||
- Status: `deferred`
|
||||
- Rationale: Current priority is lifecycle reliability and trust, not visual redesign.
|
||||
- Acceptance condition: Only revisit after Phase 2 acceptance and trust refinements.
|
||||
- Repo pointer: `src/`, `index.html`, `src/styles.css`
|
||||
BIN
app-icon.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
666
communication_translator_project_plan.md
Normal file
@ -0,0 +1,666 @@
|
||||
# Communication Translator Project Plan
|
||||
|
||||
## Working Name
|
||||
|
||||
Use a placeholder name until the product identity becomes obvious through use.
|
||||
|
||||
Suggested internal names:
|
||||
|
||||
- Thisper
|
||||
- TypeFlow
|
||||
- Fidelity Keyboard
|
||||
|
||||
For now, use a neutral working title:
|
||||
|
||||
**Project Codename: Thisper**
|
||||
|
||||
---
|
||||
|
||||
## Core Product Vision
|
||||
|
||||
Build a typing-first and speech-capable communication tool that lets me write or speak naturally and quickly, then cleans the output to improve readability **without changing my meaning or replacing my voice**.
|
||||
|
||||
This is **not** meant to be a generic AI assistant, chatbot, summarizer, or writing tool.
|
||||
|
||||
It is a **fidelity-preserving input translation system**.
|
||||
|
||||
Its job is to help me communicate the way I naturally think, while reducing the friction other people have when reading what I produce.
|
||||
|
||||
---
|
||||
|
||||
## One-Sentence Product Definition
|
||||
|
||||
A cross-application typing and speech translation layer that preserves original meaning and voice while improving readability.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Normal tools do not fit how I think or communicate.
|
||||
|
||||
Current problems:
|
||||
|
||||
- Standard autocorrect only fixes words, not readability.
|
||||
- Predictive text is shallow and often changes intent.
|
||||
- Most AI rewrite tools sound obviously AI-generated.
|
||||
- Dictation tools like Wispr Flow improve readability, but focus mainly on speech.
|
||||
- My preferred input method is typing, not speech.
|
||||
- My natural writing style is fast, dense, highly connected, and often difficult for other people to follow.
|
||||
- Existing tools either:
|
||||
- change too much,
|
||||
- flatten my tone,
|
||||
- introduce AI-sounding language,
|
||||
- or fail to preserve factual precision.
|
||||
|
||||
I need a system that acts as a **translation layer**, not a replacement voice.
|
||||
|
||||
---
|
||||
|
||||
## Why This Project Exists
|
||||
|
||||
This system exists to solve a real communication gap:
|
||||
|
||||
- I can type very quickly.
|
||||
- I think in relationships and connected meaning, not simple step-by-step output.
|
||||
- My raw writing often carries the correct meaning, but other people struggle to process the density, pacing, grammar, or structure.
|
||||
- I want a system that lets me continue writing naturally, then makes the result easier for others to read.
|
||||
- I do not want the system to rewrite me into a generic AI voice.
|
||||
- I do not want to lose factual precision, uncertainty, or emotional tone unless I explicitly request a change.
|
||||
|
||||
The goal is:
|
||||
|
||||
**clean output, preserved self**
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
This project is **not** intended to be:
|
||||
|
||||
- a full chatbot
|
||||
- a generic AI writer
|
||||
- a generic note-taking app
|
||||
- a journaling system
|
||||
- a replacement for Journal
|
||||
- a cloud-only product
|
||||
- a social media writing assistant
|
||||
- a summarizer by default
|
||||
- a grammar tool that prioritizes correctness over meaning
|
||||
- a tool that rewrites everything into professional corporate speech
|
||||
|
||||
If the project starts drifting into any of the above, stop and re-evaluate.
|
||||
|
||||
---
|
||||
|
||||
## Primary Design Principles
|
||||
|
||||
### 1. Preserve meaning
|
||||
The system must not change factual claims, uncertainty, intent, or core message unless explicitly asked.
|
||||
|
||||
### 2. Preserve voice
|
||||
The system should keep my tone, cadence, style, and general phrasing as much as possible.
|
||||
|
||||
### 3. Improve readability
|
||||
The system should make text easier for other people to read by improving punctuation, sentence boundaries, grammar, and flow.
|
||||
|
||||
### 4. Minimize AI smell
|
||||
The output should not sound like a chatbot wrote it.
|
||||
|
||||
### 5. Typing-first
|
||||
This tool must treat typing as a first-class input method, not a fallback behind speech.
|
||||
|
||||
### 6. Speech-capable
|
||||
Speech support is useful, but secondary to typing for my needs.
|
||||
|
||||
### 7. Cross-app use
|
||||
This should work across applications rather than living only inside one app.
|
||||
|
||||
### 8. Trust through transparency
|
||||
The user should be able to see what changed.
|
||||
|
||||
### 9. Speed matters
|
||||
The system should feel immediate, especially in typed workflows.
|
||||
|
||||
### 10. Pluggable intelligence
|
||||
The architecture should support local, cloud, or hybrid backends without hard-coding the project to one provider.
|
||||
|
||||
---
|
||||
|
||||
## Target Users
|
||||
|
||||
### Primary user
|
||||
Me.
|
||||
|
||||
This project is being built first to solve my own communication and translation needs.
|
||||
|
||||
### Secondary users
|
||||
People who:
|
||||
|
||||
- think faster than they comfortably communicate
|
||||
- prefer typing over speech
|
||||
- produce dense or hard-to-follow writing
|
||||
- want cleanup without losing their style
|
||||
- dislike obvious AI rewriting
|
||||
- need help bridging the gap between raw output and readable output
|
||||
|
||||
Potential overlap:
|
||||
- autistic users
|
||||
- ADHD users
|
||||
- disabled users
|
||||
- technical users
|
||||
- trauma survivors who need precision and control
|
||||
- anyone whose natural communication style does not fit normal tools
|
||||
|
||||
---
|
||||
|
||||
## User Experience Goal
|
||||
|
||||
I should be able to:
|
||||
|
||||
1. Type naturally at full speed.
|
||||
2. Speak naturally when useful.
|
||||
3. Capture raw input without friction.
|
||||
4. Run a cleanup/translation pass.
|
||||
5. Get output that is easier to read but still clearly mine.
|
||||
6. Use the result in any app.
|
||||
|
||||
The ideal feeling is:
|
||||
|
||||
**“I typed like myself, and the system made it readable without turning it into someone else.”**
|
||||
|
||||
---
|
||||
|
||||
## Core Use Cases
|
||||
|
||||
### Use Case 1: Typed cleanup
|
||||
I paste or type raw text into the tool and receive a cleaned version that preserves my voice.
|
||||
|
||||
### Use Case 2: Selected-text rewrite
|
||||
I select text in another application, trigger the tool, and get a cleaned version back.
|
||||
|
||||
### Use Case 3: Clipboard bridge
|
||||
I copy raw text, run it through the translator, and paste the improved output elsewhere.
|
||||
|
||||
### Use Case 4: Speech capture
|
||||
I speak into the system and receive a highly accurate transcript with readability cleanup.
|
||||
|
||||
### Use Case 5: Audience adaptation
|
||||
I choose a mode such as readable, concise, or formal without losing core meaning.
|
||||
|
||||
### Use Case 6: Diff review
|
||||
I inspect exactly what changed before accepting the result.
|
||||
|
||||
---
|
||||
|
||||
## Primary Modes
|
||||
|
||||
These modes should be explicit and limited. Avoid mode explosion.
|
||||
|
||||
### 1. Clean
|
||||
Fix punctuation, capitalization, sentence boundaries, whitespace, and obvious grammar issues while staying extremely close to the original.
|
||||
|
||||
### 2. Readable
|
||||
Improve clarity and flow slightly more than Clean while still preserving voice and meaning.
|
||||
|
||||
### 3. Formal
|
||||
Make the text more appropriate for legal, support, or professional contexts while preserving core message and accuracy.
|
||||
|
||||
### 4. Concise
|
||||
Reduce length without removing important meaning.
|
||||
|
||||
### 5. Preserve Voice
|
||||
The strictest style-preserving mode. Minimal cleanup, maximum fidelity.
|
||||
|
||||
Default mode should likely be:
|
||||
|
||||
**Preserve Voice** or **Clean**
|
||||
|
||||
---
|
||||
|
||||
## Transformation Rules
|
||||
|
||||
The default transformation engine must obey rules like these:
|
||||
|
||||
1. Preserve meaning exactly unless a different mode explicitly allows restructuring.
|
||||
2. Preserve uncertainty exactly.
|
||||
3. Preserve factual claims exactly.
|
||||
4. Preserve emotional tone unless asked to soften or harden it.
|
||||
5. Do not summarize unless explicitly requested.
|
||||
6. Do not inject stock AI phrases.
|
||||
7. Do not over-polish.
|
||||
8. Do not remove intensity unless needed for readability or safety.
|
||||
9. When uncertain, stay closer to the original.
|
||||
10. Always prefer fidelity over prettiness.
|
||||
|
||||
---
|
||||
|
||||
## Product Scope Strategy
|
||||
|
||||
To avoid drift, build this in phases.
|
||||
|
||||
### Phase 1: Desktop text-to-text translator
|
||||
This is the real MVP.
|
||||
|
||||
Must include:
|
||||
|
||||
- text input box
|
||||
- paste raw text
|
||||
- output pane
|
||||
- selectable modes
|
||||
- diff view
|
||||
- copy output
|
||||
- very simple settings
|
||||
- one backend at first
|
||||
- preserve-style-first behavior
|
||||
|
||||
Do not add speech yet unless it is trivial.
|
||||
|
||||
### Phase 2: System-wide desktop utility
|
||||
Add:
|
||||
|
||||
- hotkey to open translator
|
||||
- clipboard pipeline
|
||||
- selected-text workflow
|
||||
- tray app or background helper
|
||||
- faster repeated usage across apps
|
||||
|
||||
### Phase 3: Speech input
|
||||
Add:
|
||||
|
||||
- microphone capture
|
||||
- streaming or chunked transcript
|
||||
- cleaned transcript output
|
||||
- same transformation modes
|
||||
|
||||
### Phase 4: Android keyboard
|
||||
Build a real keyboard, not a fake dictation shell.
|
||||
|
||||
Must support:
|
||||
|
||||
- normal typing
|
||||
- optional cleanup button
|
||||
- optional rewrite action
|
||||
- optional dictation later
|
||||
|
||||
### Phase 5: Optional local/hybrid backends
|
||||
Add support for:
|
||||
|
||||
- local model providers
|
||||
- cloud model providers
|
||||
- fallback chains
|
||||
- user-selectable provider strategy
|
||||
|
||||
### Phase 6: Journal integration
|
||||
Only after the standalone tool proves itself.
|
||||
|
||||
Journal should consume this system, not contain its entire logic.
|
||||
|
||||
---
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### MVP Goal
|
||||
A desktop app that takes typed text and transforms it into more readable text while preserving the original voice and meaning.
|
||||
|
||||
### MVP Must Have
|
||||
- input area
|
||||
- output area
|
||||
- mode selector
|
||||
- copy button
|
||||
- diff display
|
||||
- rewrite button
|
||||
- settings for backend/mode behavior
|
||||
- at least one reliable backend
|
||||
- strong preserve-style prompt rules
|
||||
|
||||
### MVP Should Not Have
|
||||
- mobile
|
||||
- iPhone
|
||||
- full keyboard integration
|
||||
- many modes
|
||||
- user accounts
|
||||
- journaling features
|
||||
- complex profiles
|
||||
- many AI providers
|
||||
- voice-first workflow
|
||||
- massive settings surface
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
### 1. Input Layer
|
||||
Responsible for collecting text or speech.
|
||||
|
||||
Possible components:
|
||||
- text editor/input box
|
||||
- clipboard intake
|
||||
- selected-text capture
|
||||
- speech capture
|
||||
- keyboard integration later
|
||||
|
||||
### 2. Preprocessing Layer
|
||||
Responsible for lightweight cleanup before AI.
|
||||
|
||||
Examples:
|
||||
- trim whitespace
|
||||
- normalize line breaks
|
||||
- detect paragraphs
|
||||
- optional sentence hints
|
||||
- optional typo normalization
|
||||
|
||||
This layer should be deterministic where possible.
|
||||
|
||||
### 3. Transformation Layer
|
||||
Responsible for style-preserving cleanup and rewrite operations.
|
||||
|
||||
This should be abstracted behind interfaces so providers can be swapped.
|
||||
|
||||
Possible provider types:
|
||||
- cloud LLM
|
||||
- local LLM
|
||||
- hybrid chain
|
||||
- rules + LLM combination
|
||||
|
||||
### 4. Review Layer
|
||||
Responsible for trust and transparency.
|
||||
|
||||
Examples:
|
||||
- side-by-side view
|
||||
- inline diff
|
||||
- changed text highlighting
|
||||
- accept/reject whole output
|
||||
- maybe per-block review later
|
||||
|
||||
### 5. Output Layer
|
||||
Responsible for making the result usable.
|
||||
|
||||
Examples:
|
||||
- copy to clipboard
|
||||
- replace selected text
|
||||
- save to file
|
||||
- send to app
|
||||
- Journal integration later
|
||||
|
||||
---
|
||||
|
||||
## Backend Strategy
|
||||
|
||||
Backends should be pluggable.
|
||||
|
||||
Use abstractions such as:
|
||||
|
||||
- `IRewriteProvider`
|
||||
- `ITranscriptionProvider`
|
||||
- `IFormattingProvider`
|
||||
|
||||
This prevents provider lock-in.
|
||||
|
||||
### Backend priorities
|
||||
1. reliability
|
||||
2. fidelity
|
||||
3. latency
|
||||
4. low AI smell
|
||||
5. cost
|
||||
6. local support later
|
||||
|
||||
### Initial backend recommendation
|
||||
Start with one provider only.
|
||||
|
||||
Do not build a multi-provider ensemble in the MVP.
|
||||
|
||||
That can come later if needed.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Processing Pipeline
|
||||
|
||||
### Typed input pipeline
|
||||
1. User types or pastes raw text.
|
||||
2. Preprocessing normalizes text.
|
||||
3. Rewrite provider transforms according to selected mode.
|
||||
4. Diff is shown.
|
||||
5. User copies or replaces text.
|
||||
|
||||
### Speech pipeline
|
||||
1. User speaks.
|
||||
2. ASR provider transcribes in chunks or stream.
|
||||
3. Transcript is normalized.
|
||||
4. Rewrite provider applies selected cleanup mode.
|
||||
5. User reviews and accepts output.
|
||||
|
||||
---
|
||||
|
||||
## UX Requirements
|
||||
|
||||
### Required UX qualities
|
||||
- fast
|
||||
- clean
|
||||
- low friction
|
||||
- minimal clicks
|
||||
- obvious trust signals
|
||||
- easy to understand
|
||||
- no clutter
|
||||
- no aggressive AI presence
|
||||
|
||||
### Important UX rules
|
||||
- always preserve access to the raw original
|
||||
- always make changes inspectable
|
||||
- never hide major rewrites
|
||||
- do not drown the UI in settings
|
||||
- default to the safest mode
|
||||
|
||||
---
|
||||
|
||||
## Performance Goals
|
||||
|
||||
### For text-to-text
|
||||
- small inputs should feel nearly immediate
|
||||
- the UI must never freeze
|
||||
- processing should happen asynchronously
|
||||
- copy/reuse must be fast
|
||||
|
||||
### For speech later
|
||||
- transcript should appear progressively
|
||||
- cleanup should happen incrementally where possible
|
||||
- avoid long blocking waits
|
||||
- prefer “usable now, better refined in background” over “perfect after delay”
|
||||
|
||||
---
|
||||
|
||||
## Trust and Safety Philosophy
|
||||
|
||||
This is a communication aid, not a truth engine.
|
||||
|
||||
The system should:
|
||||
|
||||
- preserve what I said
|
||||
- preserve uncertainty
|
||||
- avoid hallucinating facts
|
||||
- avoid inventing claims
|
||||
- avoid changing meaning without permission
|
||||
|
||||
The most important safety rule is:
|
||||
|
||||
**Do not silently distort the message.**
|
||||
|
||||
---
|
||||
|
||||
## Privacy Philosophy
|
||||
|
||||
Privacy matters, but forcing everything local too early may block the project.
|
||||
|
||||
Approach:
|
||||
|
||||
- make privacy explicit
|
||||
- allow backend choice
|
||||
- do not hardwire cloud dependence
|
||||
- support local later
|
||||
- let the user know what leaves the device
|
||||
|
||||
The system should be able to grow toward:
|
||||
|
||||
- local-only mode
|
||||
- hybrid mode
|
||||
- cloud mode
|
||||
|
||||
But MVP can use a cloud provider if needed for quality.
|
||||
|
||||
---
|
||||
|
||||
## Integration Philosophy
|
||||
|
||||
This project should be standalone first.
|
||||
|
||||
It may later integrate with:
|
||||
|
||||
- Journal
|
||||
- editors
|
||||
- browsers
|
||||
- messaging apps
|
||||
- email workflows
|
||||
|
||||
But the core must stay focused:
|
||||
|
||||
**input translation across contexts**
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
### 1. Scope creep
|
||||
Trying to build speech, desktop, mobile keyboard, local AI, and Journal integration all at once.
|
||||
|
||||
Mitigation:
|
||||
- follow phases strictly
|
||||
- do not build future phases early
|
||||
|
||||
### 2. AI voice contamination
|
||||
Outputs become bland, generic, or chatbot-like.
|
||||
|
||||
Mitigation:
|
||||
- preserve-style prompts
|
||||
- diff review
|
||||
- strict mode rules
|
||||
- compare against original constantly
|
||||
|
||||
### 3. Provider dependence
|
||||
A cloud provider changes policy, pricing, or quality.
|
||||
|
||||
Mitigation:
|
||||
- provider abstraction
|
||||
- backend pluggability
|
||||
|
||||
### 4. Overengineering
|
||||
Building a giant architecture before proving the core use case.
|
||||
|
||||
Mitigation:
|
||||
- keep MVP small
|
||||
- prove value first
|
||||
|
||||
### 5. Latency frustration
|
||||
Tool feels too slow to be useful.
|
||||
|
||||
Mitigation:
|
||||
- async architecture
|
||||
- fast UI
|
||||
- small input workflows first
|
||||
- optimize perceived speed
|
||||
|
||||
### 6. Drift into “generic AI app”
|
||||
Project becomes another assistant shell instead of a focused translation tool.
|
||||
|
||||
Mitigation:
|
||||
- revisit product definition regularly
|
||||
- reject features that do not support the core vision
|
||||
|
||||
---
|
||||
|
||||
## Decision Filters
|
||||
|
||||
Before adding any feature, ask:
|
||||
|
||||
1. Does this help preserve meaning?
|
||||
2. Does this help preserve voice?
|
||||
3. Does this improve readability?
|
||||
4. Does this help use the tool across apps?
|
||||
5. Does this keep the tool focused?
|
||||
6. Can this wait until a later phase?
|
||||
|
||||
If the answer is unclear, do not add it yet.
|
||||
|
||||
---
|
||||
|
||||
## Immediate Development Priorities
|
||||
|
||||
### Priority 1
|
||||
Write the exact behavior spec for each mode:
|
||||
- Clean
|
||||
- Readable
|
||||
- Formal
|
||||
- Concise
|
||||
- Preserve Voice
|
||||
|
||||
### Priority 2
|
||||
Build the text-to-text desktop MVP.
|
||||
|
||||
### Priority 3
|
||||
Test outputs against real examples of my raw writing.
|
||||
|
||||
### Priority 4
|
||||
Tune prompts and system rules until the result feels like:
|
||||
- me
|
||||
- but easier to read
|
||||
|
||||
### Priority 5
|
||||
Add diff and trust tooling before getting fancy.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The project is succeeding if:
|
||||
|
||||
- I can write naturally without slowing down.
|
||||
- The output remains recognizably mine.
|
||||
- Other people can follow it more easily.
|
||||
- The text does not sound generically AI-generated.
|
||||
- I trust the system not to corrupt my meaning.
|
||||
- I can use it in multiple contexts, not just one app.
|
||||
- It reduces friction in real communication.
|
||||
|
||||
---
|
||||
|
||||
## Failure Criteria
|
||||
|
||||
The project is failing if:
|
||||
|
||||
- the output sounds like a chatbot
|
||||
- my meaning changes too often
|
||||
- it becomes another generic AI wrapper
|
||||
- it gets overloaded with features before the core works
|
||||
- the UI becomes cluttered
|
||||
- it is too slow to use comfortably
|
||||
- it only works in one narrow context
|
||||
- it stops feeling like a tool for me
|
||||
|
||||
---
|
||||
|
||||
## Final Reminder
|
||||
|
||||
This project is not about making me sound like someone else.
|
||||
|
||||
It is about making **my actual communication** more readable without losing:
|
||||
- meaning
|
||||
- tone
|
||||
- precision
|
||||
- intensity
|
||||
- identity
|
||||
|
||||
That is the standard.
|
||||
|
||||
When in doubt, return to this sentence:
|
||||
|
||||
**Clean the output. Do not replace the person.**
|
||||
86
index.html
Normal file
@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Thisper - Translator App</title>
|
||||
<link rel="stylesheet" href="/src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header class="header">
|
||||
<h1>Thisper</h1>
|
||||
<div class="controls">
|
||||
<select id="model-select">
|
||||
<option value="models/gemini-2.0-flash-lite">Gemini 2.0 Flash Lite</option>
|
||||
<option value="models/gemini-3.1-flash-lite-preview">Gemini 3.1 Flash Lite (Preview)</option>
|
||||
<option value="models/gemini-flash-lite-latest">Gemini Flash Lite (Latest)</option>
|
||||
<option value="models/gemini-2.5-flash">Gemini 2.5 Flash</option>
|
||||
</select>
|
||||
<select id="mode-select">
|
||||
<option value="Preserve Voice">Preserve Voice</option>
|
||||
<option value="Clean">Clean</option>
|
||||
<option value="Readable">Readable</option>
|
||||
<option value="Formal">Formal</option>
|
||||
<option value="Concise">Concise</option>
|
||||
</select>
|
||||
<button id="rewrite-btn" class="primary-btn">Rewrite</button>
|
||||
<button id="settings-btn" class="icon-btn" title="Settings">⚙️</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="helper-banner" class="helper-banner hidden">
|
||||
<p id="helper-banner-text"></p>
|
||||
<div class="helper-actions">
|
||||
<button id="hide-to-tray-btn">Hide to Tray</button>
|
||||
<button id="dismiss-banner-btn">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="runtime-status" class="runtime-status"></div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<header class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="close-settings-btn" class="close-btn">×</button>
|
||||
</header>
|
||||
<div class="field">
|
||||
<label for="api-key-input">Gemini API Key</label>
|
||||
<input type="password" id="api-key-input" placeholder="Paste your API key here...">
|
||||
<p class="hint">Stored in your system credential store. Thisper never writes the key to a plaintext file.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="save-settings-btn" class="primary-btn">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="panes">
|
||||
<div class="pane">
|
||||
<label for="input-text">Original Input</label>
|
||||
<textarea id="input-text" placeholder="Type or paste raw text here..."></textarea>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<label for="output-text">Optimized Output</label>
|
||||
<div id="loading" class="hidden">Parsing and translating...</div>
|
||||
<div id="diff-view" class="hidden diff-container"></div>
|
||||
<textarea id="output-text" placeholder="Output will appear here..." readonly></textarea>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p class="footer-note">
|
||||
Cross-app rewrite uses <strong>Ctrl + Alt + R</strong>. Thisper copies the current selection,
|
||||
rewrites it, and pastes the full replacement only after a complete rewrite succeeds.
|
||||
</p>
|
||||
<div class="footer-actions">
|
||||
<button id="toggle-diff-btn">Show Diff</button>
|
||||
<button id="copy-btn">Copy Output</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2297
package-lock.json
generated
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "thisper",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"licenses:rust": "cd src-tauri && cargo deny check licenses bans",
|
||||
"licenses:npm": "license-checker-rseidelsohn --production --summary --excludePrivatePackages --onlyAllow \"MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense\"",
|
||||
"licenses:all": "npm run licenses:npm && npm run licenses:rust"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@types/diff": "^7.0.2",
|
||||
"diff": "^8.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"license-checker-rseidelsohn": "^4.4.2",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
244
scripts/square_icon.py
Normal file
@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
import threading
|
||||
from PIL import Image, ImageTk, ImageFilter
|
||||
|
||||
# --- Constants & Styling ---
|
||||
BG_COLOR = "#0D1117"
|
||||
CARD_COLOR = "#21262D"
|
||||
ACCENT_COLOR = "#2F81F7"
|
||||
TEXT_COLOR = "#C9D1D9"
|
||||
TEXT_DIM = "#8B949E"
|
||||
SUCCESS_COLOR = "#238636"
|
||||
|
||||
class SquareIconGUI:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("Standard - Square Icon Studio")
|
||||
self.root.geometry("1000x700")
|
||||
self.root.configure(bg=BG_COLOR)
|
||||
|
||||
self.source_paths = []
|
||||
self.output_dir = "src-tauri/icons"
|
||||
self.tk_orig: ImageTk.PhotoImage | None = None
|
||||
self.tk_after: ImageTk.PhotoImage | None = None
|
||||
self.is_processing = False
|
||||
|
||||
self.setup_styles()
|
||||
self.setup_ui()
|
||||
|
||||
def setup_styles(self):
|
||||
style = ttk.Style()
|
||||
style.theme_use("clam")
|
||||
|
||||
style.configure("TFrame", background=BG_COLOR)
|
||||
style.configure("Card.TFrame", background=CARD_COLOR)
|
||||
style.configure("TLabel", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI", 10))
|
||||
style.configure("Header.TLabel", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI Bold", 20))
|
||||
style.configure("Sub.TLabel", background=BG_COLOR, foreground=TEXT_DIM, font=("Segoe UI", 11))
|
||||
|
||||
style.configure("Config.TLabelframe", background=BG_COLOR, foreground=TEXT_COLOR)
|
||||
style.configure("Config.TLabelframe.Label", background=BG_COLOR, foreground=TEXT_COLOR, font=("Segoe UI Bold", 10))
|
||||
|
||||
def setup_ui(self):
|
||||
# Sidebar for controls
|
||||
sidebar = ttk.Frame(self.root, padding=20)
|
||||
sidebar.pack(side="left", fill="y", padx=(0, 2))
|
||||
|
||||
ttk.Label(sidebar, text="Square Icon", style="Header.TLabel").pack(anchor="w")
|
||||
ttk.Label(sidebar, text="Studio Utility", style="Sub.TLabel").pack(anchor="w", pady=(0, 40))
|
||||
|
||||
# Main actions
|
||||
self.create_action_btn(sidebar, "Select Images", "Batch or single image", self.pick_images, ACCENT_COLOR)
|
||||
self.create_action_btn(sidebar, "Output Folder", f"Target: {self.output_dir}", self.pick_output, CARD_COLOR)
|
||||
|
||||
# Config section
|
||||
config_frame = ttk.LabelFrame(sidebar, text=" Resolution ", padding=20, style="Config.TLabelframe")
|
||||
config_frame.pack(fill="x", pady=30)
|
||||
|
||||
ttk.Label(config_frame, text="Target Size (px)").pack(anchor="w")
|
||||
self.size_var = tk.StringVar(value="1024")
|
||||
self.size_entry = tk.Entry(config_frame, textvariable=self.size_var, bg="#010409", fg=TEXT_COLOR,
|
||||
insertbackground=TEXT_COLOR, relief="flat", font=("Segoe UI", 11))
|
||||
self.size_entry.pack(fill="x", pady=(5, 0), ipady=5)
|
||||
|
||||
# Status
|
||||
self.status_var = tk.StringVar(value="Ready to process...")
|
||||
self.status_label = ttk.Label(sidebar, textvariable=self.status_var, style="Sub.TLabel", wraplength=250)
|
||||
self.status_label.pack(side="bottom", fill="x", pady=20)
|
||||
|
||||
self.process_btn = tk.Button(sidebar, text="Generate Icons", font=("Segoe UI Bold", 12),
|
||||
bg=SUCCESS_COLOR, fg="white", relief="flat", cursor="hand2",
|
||||
command=self.start_processing, padx=20, pady=12)
|
||||
self.process_btn.pack(side="bottom", fill="x")
|
||||
|
||||
# Preview Area
|
||||
preview_container = ttk.Frame(self.root, style="TFrame", padding=30)
|
||||
preview_container.pack(side="right", fill="both", expand=True)
|
||||
|
||||
title_frame = ttk.Frame(preview_container)
|
||||
title_frame.pack(fill="x", pady=(0, 20))
|
||||
ttk.Label(title_frame, text="Before vs After", font=("Segoe UI Bold", 16)).pack(side="left")
|
||||
|
||||
# Preview Boxes
|
||||
self.preview_grid = ttk.Frame(preview_container)
|
||||
self.preview_grid.pack(fill="both", expand=True)
|
||||
|
||||
# Before
|
||||
before_frame = tk.Frame(self.preview_grid, bg="#161b22", highlightbackground="#30363d", highlightthickness=1)
|
||||
before_frame.place(relx=0.05, rely=0.05, relwidth=0.42, relheight=0.8)
|
||||
self.before_img_lbl = tk.Label(before_frame, text="Original", bg="#161b22", fg=TEXT_DIM)
|
||||
self.before_img_lbl.pack(fill="both", expand=True)
|
||||
|
||||
# After
|
||||
after_frame = tk.Frame(self.preview_grid, bg="#161b22", highlightbackground="#30363d", highlightthickness=1)
|
||||
after_frame.place(relx=0.53, rely=0.05, relwidth=0.42, relheight=0.8)
|
||||
self.after_img_lbl = tk.Label(after_frame, text="Result", bg="#161b22", fg=TEXT_DIM)
|
||||
self.after_img_lbl.pack(fill="both", expand=True)
|
||||
|
||||
def create_action_btn(self, parent, text, sub, cmd, color):
|
||||
f = tk.Frame(parent, bg=BG_COLOR)
|
||||
f.pack(fill="x", pady=(0, 20))
|
||||
|
||||
b = tk.Button(f, text=text, command=cmd, bg=color, fg="white",
|
||||
font=("Segoe UI Bold", 11), relief="flat", cursor="hand2", pady=8)
|
||||
b.pack(fill="x")
|
||||
l = ttk.Label(f, text=sub, style="Sub.TLabel")
|
||||
l.pack(anchor="w", pady=(2, 0))
|
||||
|
||||
def pick_images(self):
|
||||
paths = filedialog.askopenfilenames(title="Select Source Images")
|
||||
if paths:
|
||||
self.source_paths = list(paths)
|
||||
self.status_var.set(f"Loaded {len(self.source_paths)} file(s)")
|
||||
self.update_preview(self.source_paths[0])
|
||||
|
||||
def pick_output(self):
|
||||
path = filedialog.askdirectory(title="Select Output Folder")
|
||||
if path:
|
||||
self.output_dir = path
|
||||
self.status_var.set(f"Output: {os.path.basename(path)}")
|
||||
|
||||
def update_preview(self, path):
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
|
||||
# Squared preview
|
||||
w, h = img.size
|
||||
s = max(w, h)
|
||||
square = Image.new("RGBA", (s, s), (0, 0, 0, 0))
|
||||
square.paste(img, ((s-w)//2, (s-h)//2))
|
||||
|
||||
# Constraints
|
||||
max_p = 350
|
||||
|
||||
# Thumbnails using compatible Resampling
|
||||
resample = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
img.thumbnail((max_p, max_p), resample=resample)
|
||||
self.tk_orig = ImageTk.PhotoImage(img)
|
||||
self.before_img_lbl.config(image=self.tk_orig, text="")
|
||||
|
||||
square.thumbnail((max_p, max_p), resample=resample)
|
||||
self.tk_after = ImageTk.PhotoImage(square)
|
||||
self.after_img_lbl.config(image=self.tk_after, text="")
|
||||
|
||||
except Exception as e:
|
||||
self.status_var.set(f"Preview fail: {e}")
|
||||
|
||||
def start_processing(self):
|
||||
if not self.source_paths: return
|
||||
self.is_processing = True
|
||||
self.process_btn.config(state="disabled", text="Working...")
|
||||
threading.Thread(target=self.run_logic, daemon=True).start()
|
||||
|
||||
def run_logic(self):
|
||||
try:
|
||||
size = int(self.size_var.get())
|
||||
if not os.path.exists(self.output_dir): os.makedirs(self.output_dir)
|
||||
|
||||
for i, p in enumerate(self.source_paths):
|
||||
self.status_var.set(f"Saving {i+1}/{len(self.source_paths)}...")
|
||||
make_square_icon(p, self.output_dir, size)
|
||||
|
||||
self.root.after(0, lambda: messagebox.showinfo("Done", f"Processed {len(self.source_paths)} icons!"))
|
||||
except Exception as e:
|
||||
self.root.after(0, lambda: messagebox.showerror("Error", str(e)))
|
||||
finally:
|
||||
self.root.after(0, self.reset)
|
||||
|
||||
def reset(self):
|
||||
self.is_processing = False
|
||||
self.process_btn.config(state="normal", text="Generate Icons")
|
||||
self.status_var.set("Ready for more.")
|
||||
|
||||
def make_square_icon(source_path, output_dir="src-tauri/icons", target_size=1024):
|
||||
"""
|
||||
Core logic: Takes any image and turns it into a perfectly centered,
|
||||
transparent square icon (PNG and multi-res ICO).
|
||||
"""
|
||||
if not os.path.exists(source_path): return
|
||||
|
||||
img = Image.open(source_path).convert("RGBA")
|
||||
width, height = img.size
|
||||
size = max(width, height)
|
||||
|
||||
square = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
offset = ((size - width) // 2, (size - height) // 2)
|
||||
square.paste(img, offset)
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
# Standardize naming
|
||||
base = os.path.splitext(os.path.basename(source_path))[0]
|
||||
|
||||
# 1. Detect Content Bounding Box (Auto-Zoom logic)
|
||||
# This ensures small icons are recognizable by removing empty transparent space
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
# Crop to the actual icon content
|
||||
content = img.crop(bbox)
|
||||
c_w, c_h = content.size
|
||||
# Make content square with padding
|
||||
c_size = max(c_w, c_h)
|
||||
square = Image.new("RGBA", (c_size, c_size), (0, 0, 0, 0))
|
||||
square.paste(content, ((c_size - c_w) // 2, (c_size - c_h) // 2))
|
||||
|
||||
# 2. Resampling Constants
|
||||
resample_high = getattr(Image, 'Resampling', Image).LANCZOS
|
||||
|
||||
# 3. Save Primary (High-Res) PNG
|
||||
icon_png = square.resize((target_size, target_size), resample_high)
|
||||
png_path = os.path.join(output_dir, f"{base}_icon.png" if "app-icon" not in base else "icon.png")
|
||||
icon_png.save(png_path)
|
||||
|
||||
# 4. Generate High-Quality Multi-Res for .ico (Manually Sharpened)
|
||||
icon_sizes = [256, 128, 64, 48, 32, 16]
|
||||
icon_layers = []
|
||||
|
||||
for s in icon_sizes:
|
||||
# For small sizes, apply extra sharpening to ensure legibility
|
||||
layer = square.resize((s, s), resample_high)
|
||||
if s <= 32:
|
||||
# Apply subtle sharpening for small icons
|
||||
layer = layer.filter(ImageFilter.SHARPEN)
|
||||
# Boost contrast slightly if very small (improves legibility on dark/light bars)
|
||||
if s <= 16:
|
||||
layer = layer.filter(ImageFilter.EDGE_ENHANCE_MORE)
|
||||
icon_layers.append(layer)
|
||||
|
||||
ico_path = os.path.join(output_dir, f"{base}_icon.ico" if "app-icon" not in base else "icon.ico")
|
||||
icon_layers[0].save(ico_path, sizes=[(l.width, l.height) for l in icon_layers], append_images=icon_layers[1:])
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
# CLI Mode
|
||||
make_square_icon(sys.argv[1])
|
||||
else:
|
||||
# GUI Mode
|
||||
root = tk.Tk()
|
||||
app = SquareIconGUI(root)
|
||||
root.mainloop()
|
||||
7
src-tauri/.gitignore
vendored
Normal 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
|
||||
6358
src-tauri/Cargo.lock
generated
Normal file
37
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "thisper"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["stan44"]
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "thisper_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tauri-plugin-global-shortcut = "2.3.1"
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-single-instance = "2.4.0"
|
||||
enigo = "0.1.2"
|
||||
futures-util = "0.3.32"
|
||||
keyring = "3.6.3"
|
||||
regex = "1"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
opt-level = "s"
|
||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"clipboard-manager:default",
|
||||
"global-shortcut:default"
|
||||
]
|
||||
}
|
||||
17
src-tauri/deny.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[licenses]
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"BSD-2-Clause",
|
||||
"Zlib",
|
||||
"ISC",
|
||||
"Unlicense",
|
||||
"OpenSSL",
|
||||
"Unicode-3.0",
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
unused-allowed-license = "allow"
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1010 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 961 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@ -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>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 69 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/app-icon.png
Normal file
|
After Width: | Height: | Size: 423 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 574 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 874 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 393 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
389
src-tauri/src/gemini.rs
Normal file
@ -0,0 +1,389 @@
|
||||
use crate::translator::{
|
||||
ModelId, ProviderCredentials, RewriteMode, RewriteProvider, RewriteRequest, RewriteResult,
|
||||
RewriteStream,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{StreamExt, future};
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProtectedUrl {
|
||||
token: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ProtectedText {
|
||||
text: String,
|
||||
urls: Vec<ProtectedUrl>,
|
||||
}
|
||||
|
||||
pub struct GeminiProvider;
|
||||
|
||||
impl GeminiProvider {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self)
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt(text: &str, mode: RewriteMode) -> String {
|
||||
let mode_instruction = match mode {
|
||||
RewriteMode::Clean => {
|
||||
"Fix punctuation, capitalization, and obvious typos. Stay as close to the original sentence structure as possible."
|
||||
}
|
||||
RewriteMode::Readable => {
|
||||
"Improve the flow and sentence boundaries. Break up run-on sentences. Ensure the density of Information remains high, but make it easier for a third party to digest without losing the author's personality."
|
||||
}
|
||||
RewriteMode::Formal => {
|
||||
"Translate the core message into professional communication. Maintain accuracy and factual precision. Use clear, direct language suitable for a work context."
|
||||
}
|
||||
RewriteMode::Concise => {
|
||||
"Strip away filler words and repetitions. Keep the core meaning and tone intact but reduce the word count."
|
||||
}
|
||||
RewriteMode::PreserveVoice => {
|
||||
"Perform the absolute minimum interventions required for clarity. Fix only the most egregious errors. Preserve the raw pacing and stylistic quirks entirely."
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"You are a silent translation layer. Your task is to transform raw, stream-of-consciousness writing into a polished, readable version while strictly preserving the author's unique voice, tone, and intent.\n\nRULES:\n- DO NOT repeat the original input text.\n- DO NOT add any preamble or postamble (for example: 'Here is your text').\n- DO NOT summarize.\n- DO NOT invent new facts.\n- DO NOT use generic AI-sounding phrases.\n- Preserve uncertainty, intensity, and factual precision.\n- Preserve every protected token exactly as written.\n- Preserve all links and URLs exactly. Never remove, shorten, rewrite, or relabel them.\n- OUTPUT ONLY the final translated text.\n\nTARGET MODE: {}\n\nRAW INPUT:\n{}\n\nTRANSLATED OUTPUT:",
|
||||
mode_instruction, text
|
||||
)
|
||||
}
|
||||
|
||||
fn model_name(model: &ModelId) -> &'static str {
|
||||
match model {
|
||||
ModelId::Gemini(model) => model.api_name(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_body(prompt: String) -> serde_json::Value {
|
||||
json!({
|
||||
"system_instruction": {
|
||||
"parts": [{
|
||||
"text": "You are a fidelity-preserving communication translator. Improve readability while preserving the author's meaning, voice, uncertainty, emotional tone, and any protected tokens or links exactly."
|
||||
}]
|
||||
},
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{"text": prompt}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.1,
|
||||
"responseMimeType": "text/plain"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn protect_urls(text: &str) -> ProtectedText {
|
||||
let url_regex = Regex::new(r"https?://\S+").expect("valid URL protection regex");
|
||||
let mut protected_text = text.to_string();
|
||||
let mut urls = Vec::new();
|
||||
let matches: Vec<_> = url_regex.find_iter(text).collect();
|
||||
|
||||
for (index, capture) in matches.into_iter().enumerate().rev() {
|
||||
let token = format!("[[THISPER_URL_{index}]]");
|
||||
urls.push(ProtectedUrl {
|
||||
token: token.clone(),
|
||||
value: capture.as_str().to_string(),
|
||||
});
|
||||
protected_text.replace_range(capture.range(), &token);
|
||||
}
|
||||
|
||||
urls.reverse();
|
||||
|
||||
ProtectedText {
|
||||
text: protected_text,
|
||||
urls,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_urls(text: &str, protected: &ProtectedText) -> RewriteResult<String> {
|
||||
let restored = protected
|
||||
.urls
|
||||
.iter()
|
||||
.fold(text.to_string(), |acc, protected_url| {
|
||||
acc.replace(&protected_url.token, &protected_url.value)
|
||||
});
|
||||
|
||||
let missing_urls: Vec<&str> = protected
|
||||
.urls
|
||||
.iter()
|
||||
.filter_map(|protected_url| {
|
||||
if restored.contains(&protected_url.value) {
|
||||
None
|
||||
} else {
|
||||
Some(protected_url.value.as_str())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if missing_urls.is_empty() {
|
||||
Ok(restored)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Rewrite removed protected URLs. Missing: {}",
|
||||
missing_urls.join(", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_rewrite(text: &str, protected: &ProtectedText) -> RewriteResult<String> {
|
||||
Ok(Self::restore_urls(text.trim(), protected)?
|
||||
.trim()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_sse_chunk(buffer: &mut String, chunk: &str) -> Vec<RewriteResult<String>> {
|
||||
buffer.push_str(&chunk.replace("\r\n", "\n"));
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(event_end) = buffer.find("\n\n") {
|
||||
let raw_event = buffer[..event_end].to_string();
|
||||
buffer.drain(..event_end + 2);
|
||||
|
||||
if raw_event.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
events.push(Self::parse_sse_event(&raw_event).and_then(|maybe_text| {
|
||||
maybe_text
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "__skip__".to_string())
|
||||
}));
|
||||
}
|
||||
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|result| match result {
|
||||
Err(err) if err == "__skip__" => None,
|
||||
other => Some(other),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_sse_event(event: &str) -> RewriteResult<Option<String>> {
|
||||
let mut data_lines = Vec::new();
|
||||
for line in event.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(data) = trimmed.strip_prefix("data:") {
|
||||
data_lines.push(data.trim_start());
|
||||
}
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload = data_lines.join("\n");
|
||||
if payload == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Self::extract_text_from_response(&payload).map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_text_from_response(payload: &str) -> RewriteResult<String> {
|
||||
let value: serde_json::Value = serde_json::from_str(payload)
|
||||
.map_err(|err| format!("Failed to parse Gemini stream event: {err}"))?;
|
||||
|
||||
let mut text = String::new();
|
||||
if let Some(candidates) = value["candidates"].as_array() {
|
||||
for candidate in candidates {
|
||||
if let Some(parts) = candidate["content"]["parts"].as_array() {
|
||||
for part in parts {
|
||||
if let Some(part_text) = part["text"].as_str() {
|
||||
text.push_str(part_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RewriteProvider for GeminiProvider {
|
||||
async fn rewrite(
|
||||
&self,
|
||||
request: &RewriteRequest,
|
||||
credentials: &ProviderCredentials,
|
||||
) -> RewriteResult<String> {
|
||||
if credentials.api_key.is_empty() {
|
||||
return Err("Gemini API key is not configured. Please set it in Settings.".to_string());
|
||||
}
|
||||
|
||||
let protected = Self::protect_urls(&request.text);
|
||||
let client = reqwest::Client::new();
|
||||
let prompt = Self::build_prompt(&protected.text, request.mode);
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/{}:generateContent",
|
||||
Self::model_name(&request.model)
|
||||
);
|
||||
let body = Self::request_body(prompt);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &credentials.api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to reach Gemini API: {err}"))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let err_text = res
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown API error".to_string());
|
||||
return Err(format!("Gemini API error: {err_text}"));
|
||||
}
|
||||
|
||||
let resp_json: serde_json::Value = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to decode Gemini response: {err}"))?;
|
||||
|
||||
let rewritten = Self::extract_text_from_response(&resp_json.to_string())?;
|
||||
Self::finalize_rewrite(&rewritten, &protected)
|
||||
}
|
||||
|
||||
async fn rewrite_stream(
|
||||
&self,
|
||||
request: &RewriteRequest,
|
||||
credentials: &ProviderCredentials,
|
||||
) -> RewriteResult<RewriteStream> {
|
||||
if credentials.api_key.is_empty() {
|
||||
return Err("Gemini API key is not configured. Please set it in Settings.".to_string());
|
||||
}
|
||||
|
||||
let protected = Self::protect_urls(&request.text);
|
||||
let client = reqwest::Client::new();
|
||||
let prompt = Self::build_prompt(&protected.text, request.mode);
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/{}:streamGenerateContent?alt=sse",
|
||||
Self::model_name(&request.model)
|
||||
);
|
||||
let body = Self::request_body(prompt);
|
||||
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &credentials.api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Failed to reach Gemini API: {err}"))?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let err_text = res
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown API error".to_string());
|
||||
return Err(format!("Gemini API error (stream): {err_text}"));
|
||||
}
|
||||
|
||||
let collected = res
|
||||
.bytes_stream()
|
||||
.scan(String::new(), |buffer, chunk_res| {
|
||||
let items = match chunk_res {
|
||||
Ok(chunk) => {
|
||||
let chunk_text = String::from_utf8_lossy(&chunk);
|
||||
Self::parse_sse_chunk(buffer, &chunk_text)
|
||||
}
|
||||
Err(err) => vec![Err(format!("Failed to read Gemini stream: {err}"))],
|
||||
};
|
||||
future::ready(Some(items))
|
||||
})
|
||||
.flat_map(futures_util::stream::iter)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut rewritten = String::new();
|
||||
for item in collected {
|
||||
match item {
|
||||
Ok(part) => rewritten.push_str(&part),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
let restored = Self::finalize_rewrite(&rewritten, &protected)?;
|
||||
Ok(Box::pin(futures_util::stream::once(
|
||||
async move { Ok(restored) },
|
||||
)))
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &'static str {
|
||||
"Google Gemini"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GeminiProvider;
|
||||
use crate::translator::RewriteMode;
|
||||
|
||||
#[test]
|
||||
fn prompt_includes_voice_preservation_rules() {
|
||||
let prompt = GeminiProvider::build_prompt("raw text", RewriteMode::PreserveVoice);
|
||||
assert!(prompt.contains("Preserve uncertainty, intensity, and factual precision."));
|
||||
assert!(prompt.contains("Preserve all links and URLs exactly."));
|
||||
assert!(prompt.contains(
|
||||
"TARGET MODE: Perform the absolute minimum interventions required for clarity."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_protection_round_trips_links() {
|
||||
let protected = GeminiProvider::protect_urls(
|
||||
"See https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/ for details.",
|
||||
);
|
||||
|
||||
assert!(protected.text.contains("[[THISPER_URL_0]]"));
|
||||
|
||||
let restored =
|
||||
GeminiProvider::finalize_rewrite("See [[THISPER_URL_0]] for details.", &protected)
|
||||
.unwrap();
|
||||
|
||||
assert!(restored.contains(
|
||||
"https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_rewrite_rejects_missing_protected_links() {
|
||||
let protected = GeminiProvider::protect_urls("Link: https://example.com and keep it.");
|
||||
|
||||
let error = GeminiProvider::finalize_rewrite("Link removed.", &protected).unwrap_err();
|
||||
assert!(error.contains("Rewrite removed protected URLs."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_parser_handles_chunk_boundaries() {
|
||||
let mut buffer = String::new();
|
||||
let first = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello";
|
||||
let second = "\"}]}}]}\n\ndata: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\" world\"}]}}]}\n\n";
|
||||
|
||||
let first_results = GeminiProvider::parse_sse_chunk(&mut buffer, first);
|
||||
let second_results = GeminiProvider::parse_sse_chunk(&mut buffer, second);
|
||||
|
||||
assert!(first_results.is_empty());
|
||||
assert_eq!(second_results.len(), 2);
|
||||
assert_eq!(second_results[0].as_ref().unwrap(), "Hello");
|
||||
assert_eq!(second_results[1].as_ref().unwrap(), " world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_text_joins_all_text_parts() {
|
||||
let payload =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"},{"text":" world"}]}}]}"#;
|
||||
assert_eq!(
|
||||
GeminiProvider::extract_text_from_response(payload).unwrap(),
|
||||
"Hello world"
|
||||
);
|
||||
}
|
||||
}
|
||||
608
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,608 @@
|
||||
pub mod gemini;
|
||||
pub mod translator;
|
||||
|
||||
use crate::translator::{
|
||||
ModelId, ProviderCredentials, RewriteMode, RewriteProvider, RewriteRequest,
|
||||
};
|
||||
use enigo::{Enigo, Key, KeyboardControllable};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::menu::MenuBuilder;
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow, WindowEvent};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
|
||||
|
||||
const KEYRING_SERVICE: &str = "thisper";
|
||||
const KEYRING_ACCOUNT: &str = "gemini_api_key";
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_ID: &str = "main-tray";
|
||||
const MENU_SHOW_ID: &str = "show";
|
||||
const MENU_REWRITE_ID: &str = "rewrite";
|
||||
const MENU_QUIT_ID: &str = "quit";
|
||||
const EVENT_RUNTIME_STATUS: &str = "runtime-status";
|
||||
const EVENT_TRAY_HINT: &str = "tray-hint";
|
||||
|
||||
struct AppState {
|
||||
provider: Box<dyn RewriteProvider + Send + Sync>,
|
||||
active_model: Mutex<ModelId>,
|
||||
api_key: Mutex<String>,
|
||||
runtime: Mutex<RuntimeState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RewriteMetrics {
|
||||
duration_ms: u64,
|
||||
input_length: usize,
|
||||
model: ModelId,
|
||||
success: bool,
|
||||
error_category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RuntimeStatus {
|
||||
hotkey_registered: bool,
|
||||
background_mode: bool,
|
||||
active_model: String,
|
||||
api_key_configured: bool,
|
||||
last_error: Option<String>,
|
||||
rewrite_attempts: u64,
|
||||
rewrite_successes: u64,
|
||||
rewrite_failures: u64,
|
||||
last_metrics: Option<RewriteMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RuntimeState {
|
||||
hotkey_registered: bool,
|
||||
background_mode: bool,
|
||||
tray_hint_seen: bool,
|
||||
quitting: bool,
|
||||
rewrite_attempts: u64,
|
||||
rewrite_successes: u64,
|
||||
rewrite_failures: u64,
|
||||
last_error: Option<String>,
|
||||
last_metrics: Option<RewriteMetrics>,
|
||||
}
|
||||
|
||||
fn load_saved_api_key() -> Result<Option<String>, String> {
|
||||
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
||||
.map_err(|err| format!("Failed to access credential store: {err}"))?;
|
||||
match entry.get_password() {
|
||||
Ok(password) if !password.trim().is_empty() => Ok(Some(password)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(format!("Failed to read credential store: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_configured(state: &AppState) -> bool {
|
||||
let key = state.api_key.lock().unwrap();
|
||||
!key.trim().is_empty() || std::env::var("GEMINI_API_KEY").is_ok()
|
||||
}
|
||||
|
||||
fn categorize_error(error: &str) -> String {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if lower.contains("api key") || lower.contains("credential") {
|
||||
"credentials".to_string()
|
||||
} else if lower.contains("clipboard") {
|
||||
"clipboard".to_string()
|
||||
} else if lower.contains("stream") || lower.contains("gemini") || lower.contains("http") {
|
||||
"provider".to_string()
|
||||
} else if lower.contains("window") || lower.contains("tray") {
|
||||
"window".to_string()
|
||||
} else if lower.contains("shortcut") {
|
||||
"shortcut".to_string()
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn main_window(app: &AppHandle) -> Result<WebviewWindow, String> {
|
||||
app.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.ok_or_else(|| "Main window is unavailable.".to_string())
|
||||
}
|
||||
|
||||
fn snapshot_runtime_status(state: &AppState) -> RuntimeStatus {
|
||||
let runtime = state.runtime.lock().unwrap();
|
||||
let active_model = state.active_model.lock().unwrap();
|
||||
|
||||
RuntimeStatus {
|
||||
hotkey_registered: runtime.hotkey_registered,
|
||||
background_mode: runtime.background_mode,
|
||||
active_model: active_model.to_string(),
|
||||
api_key_configured: api_key_configured(state),
|
||||
last_error: runtime.last_error.clone(),
|
||||
rewrite_attempts: runtime.rewrite_attempts,
|
||||
rewrite_successes: runtime.rewrite_successes,
|
||||
rewrite_failures: runtime.rewrite_failures,
|
||||
last_metrics: runtime.last_metrics.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_runtime_status(app: &AppHandle) {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
let status = snapshot_runtime_status(&state);
|
||||
let _ = app.emit(EVENT_RUNTIME_STATUS, status);
|
||||
}
|
||||
|
||||
fn set_background_mode(app: &AppHandle, hidden: bool) {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
{
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
runtime.background_mode = hidden;
|
||||
}
|
||||
emit_runtime_status(app);
|
||||
}
|
||||
|
||||
fn record_rewrite_attempt(app: &AppHandle) {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
{
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
runtime.rewrite_attempts += 1;
|
||||
}
|
||||
emit_runtime_status(app);
|
||||
}
|
||||
|
||||
fn record_rewrite_outcome(
|
||||
app: &AppHandle,
|
||||
input_length: usize,
|
||||
model: ModelId,
|
||||
started_at: Instant,
|
||||
result: &Result<(), String>,
|
||||
) {
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
let state: State<'_, AppState> = app.state();
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
let success = result.is_ok();
|
||||
|
||||
if success {
|
||||
runtime.rewrite_successes += 1;
|
||||
runtime.last_error = None;
|
||||
} else {
|
||||
runtime.rewrite_failures += 1;
|
||||
runtime.last_error = result.as_ref().err().cloned();
|
||||
}
|
||||
|
||||
runtime.last_metrics = Some(RewriteMetrics {
|
||||
duration_ms,
|
||||
input_length,
|
||||
model,
|
||||
success,
|
||||
error_category: result.as_ref().err().map(|err| categorize_error(err)),
|
||||
});
|
||||
|
||||
drop(runtime);
|
||||
emit_runtime_status(app);
|
||||
}
|
||||
|
||||
fn show_main_window_inner(app: &AppHandle) -> Result<(), String> {
|
||||
let window = main_window(app)?;
|
||||
window
|
||||
.unminimize()
|
||||
.map_err(|err| format!("Failed to restore main window: {err}"))?;
|
||||
window
|
||||
.show()
|
||||
.map_err(|err| format!("Failed to show main window: {err}"))?;
|
||||
window
|
||||
.set_focus()
|
||||
.map_err(|err| format!("Failed to focus main window: {err}"))?;
|
||||
set_background_mode(app, false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hide_main_window_inner(app: &AppHandle) -> Result<(), String> {
|
||||
let window = main_window(app)?;
|
||||
window
|
||||
.hide()
|
||||
.map_err(|err| format!("Failed to hide main window: {err}"))?;
|
||||
set_background_mode(app, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_tray_hint(app: &AppHandle) {
|
||||
let should_emit = {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
if runtime.tray_hint_seen {
|
||||
false
|
||||
} else {
|
||||
runtime.tray_hint_seen = true;
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_emit {
|
||||
let _ = app.emit(
|
||||
EVENT_TRAY_HINT,
|
||||
"Thisper is still running in the tray. Use Show Thisper or Quit from the tray menu.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tray(app: &AppHandle) -> Result<(), String> {
|
||||
let menu = MenuBuilder::new(app)
|
||||
.text(MENU_SHOW_ID, "Show Thisper")
|
||||
.text(MENU_REWRITE_ID, "Run Clipboard Rewrite")
|
||||
.separator()
|
||||
.text(MENU_QUIT_ID, "Quit")
|
||||
.build()
|
||||
.map_err(|err| format!("Failed to build tray menu: {err}"))?;
|
||||
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.cloned()
|
||||
.ok_or_else(|| "Tray icon is unavailable.".to_string())?;
|
||||
|
||||
TrayIconBuilder::with_id(TRAY_ID)
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id() {
|
||||
id if id == MENU_SHOW_ID => {
|
||||
let _ = show_main_window_inner(app);
|
||||
}
|
||||
id if id == MENU_REWRITE_ID => {
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = handle_global_rewrite(&app_handle).await {
|
||||
eprintln!("Tray rewrite error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
id if id == MENU_QUIT_ID => {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
{
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
runtime.quitting = true;
|
||||
}
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray: &tauri::tray::TrayIcon<_>, event| match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
| TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
} => {
|
||||
let _ = show_main_window_inner(tray.app_handle());
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|err| format!("Failed to create tray icon: {err}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_window_lifecycle(app: &AppHandle) -> Result<(), String> {
|
||||
let window = main_window(app)?;
|
||||
let app_handle = app.clone();
|
||||
|
||||
window.on_window_event(move |event| match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
let should_quit = {
|
||||
let state: State<'_, AppState> = app_handle.state();
|
||||
let runtime = state.runtime.lock().unwrap();
|
||||
runtime.quitting
|
||||
};
|
||||
|
||||
if should_quit {
|
||||
return;
|
||||
}
|
||||
|
||||
api.prevent_close();
|
||||
let hint_seen = {
|
||||
let state: State<'_, AppState> = app_handle.state();
|
||||
let runtime = state.runtime.lock().unwrap();
|
||||
runtime.tray_hint_seen
|
||||
};
|
||||
|
||||
if hint_seen {
|
||||
let _ = hide_main_window_inner(&app_handle);
|
||||
} else {
|
||||
emit_tray_hint(&app_handle);
|
||||
}
|
||||
}
|
||||
WindowEvent::Focused(false) => {
|
||||
if let Ok(window) = main_window(&app_handle) {
|
||||
if !window.is_visible().unwrap_or(true) {
|
||||
set_background_mode(&app_handle, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_api_key(
|
||||
key: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
||||
.map_err(|err| format!("Failed to access credential store: {err}"))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.map_err(|err| format!("Failed to save API key securely: {err}"))?;
|
||||
|
||||
{
|
||||
let mut key_state = state.api_key.lock().unwrap();
|
||||
*key_state = key;
|
||||
}
|
||||
|
||||
emit_runtime_status(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_api_key_status(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
Ok(api_key_configured(&state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_active_model(
|
||||
model: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let parsed_model = ModelId::from_str(&model)?;
|
||||
let mut active = state.active_model.lock().unwrap();
|
||||
*active = parsed_model;
|
||||
drop(active);
|
||||
emit_runtime_status(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn rewrite_text(
|
||||
text: String,
|
||||
mode_str: String,
|
||||
model_str: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let mode = RewriteMode::from_str(&mode_str)?;
|
||||
let model = ModelId::from_str(&model_str)?;
|
||||
let api_key = {
|
||||
let key = state.api_key.lock().unwrap();
|
||||
key.clone()
|
||||
};
|
||||
let started_at = Instant::now();
|
||||
let input_length = text.len();
|
||||
|
||||
record_rewrite_attempt(&app);
|
||||
|
||||
let request = RewriteRequest { text, mode, model };
|
||||
let credentials = ProviderCredentials { api_key };
|
||||
let result = state.provider.rewrite(&request, &credentials).await;
|
||||
|
||||
let outcome = result.as_ref().map(|_| ()).map_err(|err| err.clone());
|
||||
record_rewrite_outcome(
|
||||
&app,
|
||||
input_length,
|
||||
request.model.clone(),
|
||||
started_at,
|
||||
&outcome,
|
||||
);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_runtime_status(state: State<'_, AppState>) -> Result<RuntimeStatus, String> {
|
||||
Ok(snapshot_runtime_status(&state))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_main_window(app: AppHandle) -> Result<(), String> {
|
||||
show_main_window_inner(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn hide_main_window(app: AppHandle) -> Result<(), String> {
|
||||
hide_main_window_inner(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_clipboard_rewrite(app: AppHandle) -> Result<(), String> {
|
||||
handle_global_rewrite(&app).await
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let initial_key = load_saved_api_key()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let provider =
|
||||
Box::new(gemini::GeminiProvider::new().expect("Gemini provider initialization failed"))
|
||||
as Box<dyn RewriteProvider + Send + Sync>;
|
||||
|
||||
let rewrite_shortcut = Shortcut::new(
|
||||
Some(
|
||||
tauri_plugin_global_shortcut::Modifiers::CONTROL
|
||||
| tauri_plugin_global_shortcut::Modifiers::ALT,
|
||||
),
|
||||
tauri_plugin_global_shortcut::Code::KeyR,
|
||||
);
|
||||
let shortcut_clone = rewrite_shortcut.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
let _ = show_main_window_inner(app);
|
||||
}))
|
||||
.manage(AppState {
|
||||
provider,
|
||||
active_model: Mutex::new(ModelId::default()),
|
||||
api_key: Mutex::new(initial_key),
|
||||
runtime: Mutex::new(RuntimeState::default()),
|
||||
})
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(move |app, shortcut, event| {
|
||||
if event.state() == tauri_plugin_global_shortcut::ShortcutState::Pressed
|
||||
&& shortcut == &rewrite_shortcut
|
||||
{
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = handle_global_rewrite(&app_handle).await {
|
||||
eprintln!("Global rewrite error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.setup(move |app| {
|
||||
build_tray(&app.handle()).map_err(std::io::Error::other)?;
|
||||
attach_window_lifecycle(&app.handle()).map_err(std::io::Error::other)?;
|
||||
app.global_shortcut().register(shortcut_clone.clone())?;
|
||||
|
||||
{
|
||||
let state: State<'_, AppState> = app.state();
|
||||
let mut runtime = state.runtime.lock().unwrap();
|
||||
runtime.hotkey_registered = true;
|
||||
}
|
||||
|
||||
emit_runtime_status(&app.handle());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
rewrite_text,
|
||||
set_active_model,
|
||||
save_api_key,
|
||||
get_api_key_status,
|
||||
get_runtime_status,
|
||||
show_main_window,
|
||||
hide_main_window,
|
||||
run_clipboard_rewrite
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
async fn handle_global_rewrite(app: &AppHandle) -> Result<(), String> {
|
||||
let state: State<'_, AppState> = app.state();
|
||||
let model = {
|
||||
let active = state.active_model.lock().unwrap();
|
||||
active.clone()
|
||||
};
|
||||
|
||||
record_rewrite_attempt(app);
|
||||
|
||||
let api_key = {
|
||||
let key = state.api_key.lock().unwrap();
|
||||
key.clone()
|
||||
};
|
||||
|
||||
if api_key.trim().is_empty() {
|
||||
let _ = show_main_window_inner(app);
|
||||
let result = Err(
|
||||
"Gemini API key is not configured. Open Thisper and save a key in Settings."
|
||||
.to_string(),
|
||||
);
|
||||
record_rewrite_outcome(app, 0, model, Instant::now(), &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut enigo = Enigo::new();
|
||||
let clipboard = app.clipboard();
|
||||
let original_clipboard = clipboard.read_text().ok();
|
||||
|
||||
enigo.key_up(Key::Alt);
|
||||
enigo.key_up(Key::Control);
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
enigo.key_down(Key::Control);
|
||||
enigo.key_click(Key::Layout('c'));
|
||||
enigo.key_up(Key::Control);
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
|
||||
let text = match clipboard.read_text() {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
let result = Err(format!("Failed to read clipboard text: {err}"));
|
||||
record_rewrite_outcome(app, 0, model, started_at, &result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
if text.trim().is_empty() {
|
||||
let result = Ok(());
|
||||
record_rewrite_outcome(app, 0, model, started_at, &result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let request = RewriteRequest {
|
||||
text,
|
||||
mode: RewriteMode::PreserveVoice,
|
||||
model: model.clone(),
|
||||
};
|
||||
let input_length = request.text.len();
|
||||
let credentials = ProviderCredentials { api_key };
|
||||
|
||||
let result = async {
|
||||
let mut stream = state
|
||||
.provider
|
||||
.rewrite_stream(&request, &credentials)
|
||||
.await?;
|
||||
let mut rewritten = String::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(part) => rewritten.push_str(&part),
|
||||
Err(err) => {
|
||||
if let Some(previous) = original_clipboard.as_ref() {
|
||||
let _ = clipboard.write_text(previous.clone());
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rewritten = rewritten.trim().to_string();
|
||||
if rewritten.is_empty() {
|
||||
if let Some(previous) = original_clipboard.as_ref() {
|
||||
let _ = clipboard.write_text(previous.clone());
|
||||
}
|
||||
return Err("Rewrite returned no text.".to_string());
|
||||
}
|
||||
|
||||
clipboard
|
||||
.write_text(rewritten)
|
||||
.map_err(|err| format!("Failed to stage rewritten text in clipboard: {err}"))?;
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
enigo.key_down(Key::Control);
|
||||
enigo.key_click(Key::Layout('v'));
|
||||
enigo.key_up(Key::Control);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
if let Some(previous) = original_clipboard {
|
||||
let _ = clipboard.write_text(previous);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
record_rewrite_outcome(app, input_length, model, started_at, &result);
|
||||
result
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
thisper_lib::run()
|
||||
}
|
||||
183
src-tauri/src/translator.rs
Normal file
@ -0,0 +1,183 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::pin::Pin;
|
||||
use std::str::FromStr;
|
||||
|
||||
use futures_util::Stream;
|
||||
|
||||
pub type RewriteResult<T> = Result<T, String>;
|
||||
pub type RewriteStream = Pin<Box<dyn Stream<Item = RewriteResult<String>> + Send>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RewriteMode {
|
||||
Clean,
|
||||
Readable,
|
||||
Formal,
|
||||
Concise,
|
||||
PreserveVoice,
|
||||
}
|
||||
|
||||
impl FromStr for RewriteMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"Clean" => Ok(Self::Clean),
|
||||
"Readable" => Ok(Self::Readable),
|
||||
"Formal" => Ok(Self::Formal),
|
||||
"Concise" => Ok(Self::Concise),
|
||||
"Preserve Voice" => Ok(Self::PreserveVoice),
|
||||
_ => Err(format!("Unknown mode: {value}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RewriteMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let value = match self {
|
||||
Self::Clean => "Clean",
|
||||
Self::Readable => "Readable",
|
||||
Self::Formal => "Formal",
|
||||
Self::Concise => "Concise",
|
||||
Self::PreserveVoice => "Preserve Voice",
|
||||
};
|
||||
|
||||
f.write_str(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GeminiModel {
|
||||
Gemini20FlashLite,
|
||||
Gemini31FlashLitePreview,
|
||||
GeminiFlashLiteLatest,
|
||||
Gemini25Flash,
|
||||
}
|
||||
|
||||
impl GeminiModel {
|
||||
pub fn api_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Gemini20FlashLite => "models/gemini-2.0-flash-lite",
|
||||
Self::Gemini31FlashLitePreview => "models/gemini-3.1-flash-lite-preview",
|
||||
Self::GeminiFlashLiteLatest => "models/gemini-flash-lite-latest",
|
||||
Self::Gemini25Flash => "models/gemini-2.5-flash",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GeminiModel {
|
||||
fn default() -> Self {
|
||||
Self::Gemini20FlashLite
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GeminiModel {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"models/gemini-2.0-flash-lite" => Ok(Self::Gemini20FlashLite),
|
||||
"models/gemini-3.1-flash-lite-preview" => Ok(Self::Gemini31FlashLitePreview),
|
||||
"models/gemini-flash-lite-latest" => Ok(Self::GeminiFlashLiteLatest),
|
||||
"models/gemini-2.5-flash" => Ok(Self::Gemini25Flash),
|
||||
_ => Err(format!("Unknown model: {value}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for GeminiModel {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.api_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ModelId {
|
||||
Gemini(GeminiModel),
|
||||
}
|
||||
|
||||
impl Default for ModelId {
|
||||
fn default() -> Self {
|
||||
Self::Gemini(GeminiModel::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelId {
|
||||
pub fn api_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Gemini(model) => model.api_name(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ModelId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.api_name())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ModelId {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self::Gemini(GeminiModel::from_str(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewriteRequest {
|
||||
pub text: String,
|
||||
pub mode: RewriteMode,
|
||||
pub model: ModelId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderCredentials {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RewriteProvider: Send + Sync {
|
||||
async fn rewrite(
|
||||
&self,
|
||||
request: &RewriteRequest,
|
||||
credentials: &ProviderCredentials,
|
||||
) -> RewriteResult<String>;
|
||||
|
||||
async fn rewrite_stream(
|
||||
&self,
|
||||
request: &RewriteRequest,
|
||||
credentials: &ProviderCredentials,
|
||||
) -> RewriteResult<RewriteStream>;
|
||||
|
||||
fn provider_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GeminiModel, ModelId, RewriteMode};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn rewrite_mode_parses_known_values() {
|
||||
assert_eq!(RewriteMode::from_str("Clean").unwrap(), RewriteMode::Clean);
|
||||
assert_eq!(
|
||||
RewriteMode::from_str("Preserve Voice").unwrap(),
|
||||
RewriteMode::PreserveVoice
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_mode_rejects_unknown_values() {
|
||||
assert!(RewriteMode::from_str("Aggressive").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_id_parses_known_values() {
|
||||
assert_eq!(
|
||||
ModelId::from_str("models/gemini-2.5-flash").unwrap(),
|
||||
ModelId::Gemini(GeminiModel::Gemini25Flash)
|
||||
);
|
||||
}
|
||||
}
|
||||
38
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Thisper",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.stan44.thisper",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Thisper",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"publisher": "Stan44",
|
||||
"copyright": "Copyright © 2026 Stan44. All rights reserved.",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
src/assets/tauri.svg
Normal 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 |
25
src/assets/typescript.svg
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#2D79C7" stroke="none">
|
||||
<path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247
|
||||
-6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22
|
||||
2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70
|
||||
c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z
|
||||
m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3
|
||||
0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39
|
||||
-175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30
|
||||
-20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613
|
||||
-131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3
|
||||
245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57
|
||||
233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148
|
||||
-374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448
|
||||
682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0
|
||||
-915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/vite.svg
Normal 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 |
255
src/main.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import * as diff from "diff";
|
||||
|
||||
type RewriteMetrics = {
|
||||
duration_ms: number;
|
||||
input_length: number;
|
||||
model: string;
|
||||
success: boolean;
|
||||
error_category?: string | null;
|
||||
};
|
||||
|
||||
type RuntimeStatus = {
|
||||
hotkey_registered: boolean;
|
||||
background_mode: boolean;
|
||||
active_model: string;
|
||||
api_key_configured: boolean;
|
||||
last_error?: string | null;
|
||||
rewrite_attempts: number;
|
||||
rewrite_successes: number;
|
||||
rewrite_failures: number;
|
||||
last_metrics?: RewriteMetrics | null;
|
||||
};
|
||||
|
||||
function requireElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing required element: ${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const inputEl = requireElement<HTMLTextAreaElement>("input-text");
|
||||
const outputEl = requireElement<HTMLTextAreaElement>("output-text");
|
||||
const modelSelect = requireElement<HTMLSelectElement>("model-select");
|
||||
const modeSelect = requireElement<HTMLSelectElement>("mode-select");
|
||||
const rewriteBtn = requireElement<HTMLButtonElement>("rewrite-btn");
|
||||
const copyBtn = requireElement<HTMLButtonElement>("copy-btn");
|
||||
const toggleDiffBtn = requireElement<HTMLButtonElement>("toggle-diff-btn");
|
||||
const diffView = requireElement<HTMLDivElement>("diff-view");
|
||||
const loadingMsg = requireElement<HTMLDivElement>("loading");
|
||||
const settingsBtn = requireElement<HTMLButtonElement>("settings-btn");
|
||||
const settingsModal = requireElement<HTMLDivElement>("settings-modal");
|
||||
const closeSettingsBtn = requireElement<HTMLButtonElement>("close-settings-btn");
|
||||
const saveSettingsBtn = requireElement<HTMLButtonElement>("save-settings-btn");
|
||||
const apiKeyInput = requireElement<HTMLInputElement>("api-key-input");
|
||||
const runtimeStatusEl = requireElement<HTMLDivElement>("runtime-status");
|
||||
const helperBanner = requireElement<HTMLDivElement>("helper-banner");
|
||||
const helperBannerText = requireElement<HTMLParagraphElement>("helper-banner-text");
|
||||
const hideToTrayBtn = requireElement<HTMLButtonElement>("hide-to-tray-btn");
|
||||
const dismissBannerBtn = requireElement<HTMLButtonElement>("dismiss-banner-btn");
|
||||
|
||||
let showDiff = false;
|
||||
|
||||
function renderRuntimeStatus(status: RuntimeStatus) {
|
||||
const metricParts: string[] = [];
|
||||
if (status.last_metrics) {
|
||||
metricParts.push(`${status.last_metrics.duration_ms} ms`);
|
||||
metricParts.push(`${status.last_metrics.input_length} chars`);
|
||||
metricParts.push(status.last_metrics.success ? "last run ok" : "last run failed");
|
||||
if (status.last_metrics.error_category) {
|
||||
metricParts.push(status.last_metrics.error_category);
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Hotkey: ${status.hotkey_registered ? "Ctrl + Alt + R active" : "not registered"}`,
|
||||
`Window: ${status.background_mode ? "hidden to tray" : "visible"}`,
|
||||
`Model: ${status.active_model}`,
|
||||
`API key: ${status.api_key_configured ? "configured" : "missing"}`,
|
||||
`Rewrites: ${status.rewrite_successes}/${status.rewrite_attempts} successful`,
|
||||
];
|
||||
|
||||
if (metricParts.length > 0) {
|
||||
lines.push(`Last run: ${metricParts.join(" | ")}`);
|
||||
}
|
||||
|
||||
if (status.last_error) {
|
||||
lines.push(`Last error: ${status.last_error}`);
|
||||
}
|
||||
|
||||
runtimeStatusEl.textContent = lines.join(" | ");
|
||||
}
|
||||
|
||||
async function refreshRuntimeStatus() {
|
||||
try {
|
||||
const status = await invoke<RuntimeStatus>("get_runtime_status");
|
||||
renderRuntimeStatus(status);
|
||||
} catch (error) {
|
||||
console.error("Failed to load runtime status", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiff(original: string, rewritten: string) {
|
||||
const diffResult = diff.diffWords(original, rewritten);
|
||||
diffView.innerHTML = "";
|
||||
diffResult.forEach((part) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = part.value;
|
||||
if (part.added) {
|
||||
span.className = "diff-add";
|
||||
} else if (part.removed) {
|
||||
span.className = "diff-del";
|
||||
}
|
||||
diffView.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
function showBanner(message: string) {
|
||||
helperBannerText.textContent = message;
|
||||
helperBanner.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
helperBanner.classList.add("hidden");
|
||||
}
|
||||
|
||||
modelSelect.addEventListener("change", async () => {
|
||||
try {
|
||||
await invoke("set_active_model", { model: modelSelect.value });
|
||||
await refreshRuntimeStatus();
|
||||
} catch (error) {
|
||||
console.error("Failed to sync active model", error);
|
||||
}
|
||||
});
|
||||
|
||||
void invoke("set_active_model", { model: modelSelect.value })
|
||||
.then(refreshRuntimeStatus)
|
||||
.catch((error) => {
|
||||
console.error("Failed to initialize active model", error);
|
||||
});
|
||||
|
||||
settingsBtn.addEventListener("click", () => {
|
||||
settingsModal.classList.remove("hidden");
|
||||
});
|
||||
|
||||
closeSettingsBtn.addEventListener("click", () => {
|
||||
settingsModal.classList.add("hidden");
|
||||
});
|
||||
|
||||
dismissBannerBtn.addEventListener("click", hideBanner);
|
||||
|
||||
hideToTrayBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("hide_main_window");
|
||||
hideBanner();
|
||||
await refreshRuntimeStatus();
|
||||
} catch (error) {
|
||||
console.error("Failed to hide main window", error);
|
||||
}
|
||||
});
|
||||
|
||||
saveSettingsBtn.addEventListener("click", async () => {
|
||||
const key = apiKeyInput.value.trim();
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
saveSettingsBtn.disabled = true;
|
||||
saveSettingsBtn.textContent = "Saving...";
|
||||
await invoke("save_api_key", { key });
|
||||
settingsModal.classList.add("hidden");
|
||||
apiKeyInput.value = "";
|
||||
await refreshRuntimeStatus();
|
||||
alert("API key saved to your system credential store.");
|
||||
} catch (err) {
|
||||
alert("Error saving API key: " + err);
|
||||
} finally {
|
||||
saveSettingsBtn.disabled = false;
|
||||
saveSettingsBtn.textContent = "Save Settings";
|
||||
}
|
||||
});
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const isSet = await invoke<boolean>("get_api_key_status");
|
||||
if (!isSet) {
|
||||
settingsModal.classList.remove("hidden");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Status check failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
rewriteBtn.addEventListener("click", async () => {
|
||||
const text = inputEl.value;
|
||||
if (!text.trim()) return;
|
||||
|
||||
loadingMsg.classList.remove("hidden");
|
||||
outputEl.classList.add("hidden");
|
||||
diffView.classList.add("hidden");
|
||||
rewriteBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const mode = modeSelect.value;
|
||||
const model = modelSelect.value;
|
||||
const result = await invoke<string>("rewrite_text", {
|
||||
text,
|
||||
modeStr: mode,
|
||||
modelStr: model,
|
||||
});
|
||||
|
||||
outputEl.value = result;
|
||||
renderDiff(text, result);
|
||||
|
||||
if (showDiff) {
|
||||
diffView.classList.remove("hidden");
|
||||
} else {
|
||||
outputEl.classList.remove("hidden");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Rewrite failed:", error);
|
||||
outputEl.value = `Error: ${error}`;
|
||||
outputEl.classList.remove("hidden");
|
||||
} finally {
|
||||
loadingMsg.classList.add("hidden");
|
||||
rewriteBtn.disabled = false;
|
||||
await refreshRuntimeStatus();
|
||||
}
|
||||
});
|
||||
|
||||
toggleDiffBtn.addEventListener("click", () => {
|
||||
showDiff = !showDiff;
|
||||
if (showDiff) {
|
||||
toggleDiffBtn.textContent = "Show Text";
|
||||
outputEl.classList.add("hidden");
|
||||
diffView.classList.remove("hidden");
|
||||
} else {
|
||||
toggleDiffBtn.textContent = "Show Diff";
|
||||
diffView.classList.add("hidden");
|
||||
outputEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(outputEl.value);
|
||||
const originalText = copyBtn.textContent;
|
||||
copyBtn.textContent = "Copied!";
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = originalText;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy", error);
|
||||
}
|
||||
});
|
||||
|
||||
void listen<string>("tray-hint", (event) => {
|
||||
showBanner(event.payload);
|
||||
});
|
||||
|
||||
void listen<RuntimeStatus>("runtime-status", (event) => {
|
||||
renderRuntimeStatus(event.payload);
|
||||
});
|
||||
|
||||
void Promise.all([checkStatus(), refreshRuntimeStatus()]);
|
||||
312
src/styles.css
Normal file
@ -0,0 +1,312 @@
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--bg-panel: #262626;
|
||||
--text: #ffffff;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--border: #3f3f46;
|
||||
--delete-bg: rgba(239, 68, 68, 0.2);
|
||||
--delete-text: #fca5a5;
|
||||
--insert-bg: rgba(34, 197, 94, 0.2);
|
||||
--insert-text: #86efac;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.helper-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.helper-banner p {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.helper-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.runtime-status {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
select, button {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-height: 0; /* Important for flex child scrolling */
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-panel);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
padding: 1rem;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
color: var(--text);
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.diff-del {
|
||||
background: var(--delete-bg);
|
||||
color: var(--delete-text);
|
||||
text-decoration: line-through;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
background: var(--insert-bg);
|
||||
color: var(--insert-text);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Modal & Settings Styles */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header,
|
||||
.footer,
|
||||
.helper-banner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.controls,
|
||||
.footer-actions,
|
||||
.helper-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
23
tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
30
vite.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||