mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Enhance Linux Wayland support and refine transcription logic
- Added instructions for Linux Wayland paste setup in README.md. - Removed unnecessary JavaScript file and updated references in HTML files. - Updated DefaultAIPrompt to clarify the role of the transcription tool. - Removed overlay notifications for Linux to reduce desktop clutter. - Implemented checks to ensure transcript integrity during refinement. - Added tests to validate transcript preservation logic. - Removed ydotool setup script as it is no longer needed.
This commit is contained in:
@@ -56,6 +56,10 @@ A high-performance, cross-platform (Windows & Linux) voice dictation application
|
||||
|
||||
Settings are managed via the built-in UI (Right-click tray → Settings) or manually in `%USERPROFILE%\.wis-free-v3\config.json`.
|
||||
|
||||
### Linux Wayland Paste Setup
|
||||
|
||||
If Settings says automatic paste needs `ydotool` setup, run the commands shown there. After those commands finish, restart your computer, then open WIS Free V3 again.
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "gsk_...",
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const r of l.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function o(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerpolicy&&(l.referrerPolicy=i.referrerpolicy),i.crossorigin==="use-credentials"?l.credentials="include":i.crossorigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function n(i){if(i.ep)return;i.ep=!0;const l=o(i);fetch(i.href,l)}})();let u=!1;window.toggleApiKey=function(){const e=document.getElementById("apiKey"),t=document.getElementById("eyeBtn");u=!u,e.type=u?"text":"password",t.textContent=u?"\u{1F648}":"\u{1F441}\uFE0F"};window.recordShortcut=function(){const e=document.getElementById("recordBtn"),t=document.getElementById("shortcutInput"),o=a=>a.metaKey||a.key==="Meta"||a.key==="OS"||a.code==="MetaLeft"||a.code==="MetaRight";e.textContent="Recording...",e.disabled=!0,t.value="";let n="";const i=a=>{a.preventDefault(),a.stopPropagation();const c=[];a.ctrlKey&&c.push("ctrl"),a.altKey&&c.push("alt"),a.shiftKey&&c.push("shift"),o(a)&&c.push("win");let s=a.key.toLowerCase();s==="control"&&(s="ctrl"),s==="alt"&&(s="alt"),s==="shift"&&(s="shift"),(s==="meta"||s==="os")&&(s="win"),(a.code==="MetaLeft"||a.code==="MetaRight")&&(s="win"),s===" "&&(s="space");const f=new Set(c);f.add(s);const g=Array.from(f).filter(w=>w!==s);g.push(s),n=g.join("+"),t.value=n,["ctrl","alt","shift","win"].includes(s)||r()},l=a=>{a.preventDefault(),a.stopPropagation(),n&&(n.split("+").every(s=>["ctrl","alt","shift","win"].includes(s))&&n.includes("+")?setTimeout(r,100):!a.ctrlKey&&!a.altKey&&!a.shiftKey&&!o(a)&&r())},r=()=>{if(!n){e.textContent="Record",e.disabled=!1,y();return}window.go.main.App.SaveSettings({shortcut:n}).then(a=>{a&&a.indexOf("Invalid shortcut")!==-1&&alert(a),e.textContent="Record",e.disabled=!1,y()})},y=()=>{window.removeEventListener("keydown",i,!0),window.removeEventListener("keyup",l,!0)};window.addEventListener("keydown",i,!0),window.addEventListener("keyup",l,!0)};function m(e){const t=document.getElementById("historyList");if(!e||e.length===0){t.innerHTML='<div style="text-align: center; color: var(--text-2); padding: 24px 0; font-size: 13px;">No history yet.</div>';return}t.innerHTML="",[...e].reverse().forEach(o=>{const n=document.createElement("div");n.className="history-item";const i=document.createElement("span");i.className="history-time",i.textContent=new Date(o.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const l=document.createElement("div");l.textContent=o.text,n.appendChild(i),n.appendChild(l),t.appendChild(n)})}function h(e){const t=document.getElementById("linuxYdotoolStatus");if(!t||!e)return;const o=!!e.ready;t.style.display="block",t.style.background=o?"var(--green-dim)":"var(--red-dim)",t.style.color=o?"var(--green)":"var(--red)",t.style.border=o?"1px solid rgba(61, 186, 110, 0.25)":"1px solid rgba(224, 82, 82, 0.25)",t.innerHTML="";const n=document.createElement("div");n.style.fontWeight="600",n.textContent=o?"Automatic paste ready":"Automatic paste needs ydotool setup",t.appendChild(n);const i=document.createElement("div");if(i.style.marginTop="4px",i.textContent=e.message||"",t.appendChild(i),!o&&Array.isArray(e.setup_commands)&&e.setup_commands.length){const l=document.createElement("pre");l.style.whiteSpace="pre-wrap",l.style.margin="8px 0 0",l.style.color="inherit",l.textContent=e.setup_commands.join(`
|
||||
`),t.appendChild(l)}}async function v(){try{const e=await window.go.main.App.GetSettings();m(e.history||[])}catch(e){console.error("Failed to refresh history:",e)}}async function E(){try{const e=await window.go.main.App.GetSettings();document.getElementById("apiKey").value=e.api_key||"";const t=document.getElementById("shortcutInput");if(t&&(t.value=e.shortcut||"alt+z"),e.linux_press_mode){const n=document.getElementById("linuxPressSection"),i=document.getElementById("linuxPressCommand");n&&(n.style.display="block"),i&&(i.value=e.linux_press_command||""),h(e.linux_ydotool_status)}document.getElementById("whisperModel").value=e.whisper_model||"whisper-large-v3-turbo",document.getElementById("aiModel").value=e.ai_model||"llama-3.3-70b-versatile",document.getElementById("aiPrompt").value=e.ai_prompt||"",document.getElementById("language").value=e.language||"en",document.getElementById("startupToggle").checked=e.startup||!1;const o=e.app_version&&String(e.app_version).trim()||"dev";document.getElementById("appVersion").textContent=o==="dev"?"dev":"v"+o,m(e.history||[])}catch(e){console.error("Failed to load settings:",e)}}async function I(){try{const e=await window.go.main.App.GetMicrophones(),t=document.getElementById("micDevice");e.forEach(o=>{if(o.index!==-1){const n=document.createElement("option");n.value=o.index,n.textContent=o.name,t.appendChild(n)}})}catch(e){console.error("Failed to load mics:",e)}}window.saveApiKey=async function(){const e=document.getElementById("apiKey").value.trim();await window.go.main.App.SaveSettings({api_key:e}),d()};function d(e="Saved"){const t=document.getElementById("saveStatus");t.textContent=e,t.classList.add("show"),setTimeout(()=>t.classList.remove("show"),2e3)}window.saveWhisperModel=async function(){await window.go.main.App.SaveSettings({whisper_model:document.getElementById("whisperModel").value}),d()};window.saveAiModel=async function(){await window.go.main.App.SaveSettings({ai_model:document.getElementById("aiModel").value}),d()};window.saveLanguage=async function(){await window.go.main.App.SaveSettings({language:document.getElementById("language").value}),d()};window.saveMicDevice=async function(){const e=document.getElementById("micDevice").value;await window.go.main.App.SaveSettings({microphone_device:e==="default"?null:parseInt(e)}),d()};window.savePrompt=async function(){await window.go.main.App.SaveSettings({ai_prompt:document.getElementById("aiPrompt").value.trim()}),d("Prompt saved")};window.copyLinuxPressCommand=async function(){const e=document.getElementById("linuxPressCommand");if(!e)return;const t=e.value||"";try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(e.focus(),e.select(),document.execCommand("copy")),d("Command copied")}catch(o){console.error("Failed to copy Linux command:",o)}};window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById("startupToggle").checked)};window.clearHistory=async function(){confirm("Clear all transcription history?")&&(await window.go.main.App.ClearHistory(),m([]))};async function x(){try{const e=await window.go.main.App.CheckOnline(),t=document.getElementById("connectionStatus");if(e)t.innerHTML="\u25CF Online",t.style.background="var(--green-dim)",t.style.color="var(--green)",t.style.border="1px solid rgba(61, 186, 110, 0.2)";else{t.innerHTML="\u25CF Offline \u2014 install local Whisper for transcription",t.style.background="var(--red-dim)",t.style.color="var(--red)",t.style.border="1px solid rgba(224, 82, 82, 0.2)";const o=document.getElementById("aiModel");for(let n of o.options)n.value!=="None"&&!n.value.startsWith("local-")&&(n.disabled=!0,n.text.includes("(offline)")||(n.text+=" (offline)"))}}catch(e){console.error("Connection check failed:",e)}}async function p(){try{const e=await window.go.main.App.GetWhisperInfo(),t=document.getElementById("whisperModel"),o=t.querySelector('option[value^="local-"]');if(o&&o.remove(),e.installed){document.getElementById("whisperNotInstalled").style.display="none",document.getElementById("whisperInstalled").style.display="block",document.getElementById("installedModel").textContent=e.model+" model";const n=document.createElement("option");n.value="local-"+e.model,n.textContent="\u2B21 Local \u2014 "+e.model+" (offline)",n.style.fontWeight="bold",t.insertBefore(n,t.firstChild)}else document.getElementById("whisperNotInstalled").style.display="block",document.getElementById("whisperInstalled").style.display="none"}catch(e){console.error("Whisper status check failed:",e)}}window.installWhisper=async function(){const e=document.getElementById("whisperModelSelect").value,t=document.getElementById("installBtn");t.disabled=!0,t.textContent="Starting installation...";try{const o=await window.go.main.App.InstallWhisper(e);alert(o+`
|
||||
|
||||
A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(p,5e3)}catch(o){alert("Installation failed: "+o)}t.disabled=!1,t.textContent="Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("Uninstall offline Whisper? This will delete the downloaded model."))try{const e=await window.go.main.App.UninstallWhisper();alert(e),p()}catch(e){alert("Uninstall failed: "+e)}};async function S(){try{if(navigator.userAgent.toLowerCase().includes("linux")){const t=document.getElementById("offlineWhisperSection");t&&(t.style.display="none");const o=document.getElementById("startupLabel");o&&(o.innerText="Run on Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await E(),await I(),await x(),await p(),window.runtime&&typeof window.runtime.EventsOn=="function"&&window.runtime.EventsOn("history:updated",function(){v()}),navigator.userAgent.toLowerCase().includes("linux")){const e=document.getElementById("whisperModel");e&&Array.from(e.options).forEach(t=>{t.value.startsWith("local-")&&t.remove()})}}S();
|
||||
+4
File diff suppressed because one or more lines are too long
Vendored
+2
-1
@@ -6,7 +6,7 @@
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Wisp Settings</title>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index.27c7f0d4.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index.839c3199.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index.6e77aa4d.css">
|
||||
</head>
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
</div>
|
||||
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
|
||||
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
|
||||
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
|
||||
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
</div>
|
||||
<p class="hint">GNOME: Settings -> Keyboard -> Custom Shortcuts -> Add a shortcut with the copied command.</p>
|
||||
<p class="hint">KDE: System Settings -> Shortcuts -> Command/URL -> Add a shortcut with the copied command.</p>
|
||||
<p class="hint">After running the ydotool setup commands, restart your computer, then open WIS Free V3 again.</p>
|
||||
<div id="linuxYdotoolStatus" style="display: none; margin-top: 12px; padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.45;"></div>
|
||||
</div>
|
||||
|
||||
@@ -347,6 +348,12 @@
|
||||
pre.style.color = 'inherit';
|
||||
pre.textContent = status.setup_commands.join('\n');
|
||||
el.appendChild(pre);
|
||||
|
||||
const restart = document.createElement('div');
|
||||
restart.style.marginTop = '8px';
|
||||
restart.style.fontWeight = '600';
|
||||
restart.textContent = 'Restart your computer after setup, then open WIS Free V3 again.';
|
||||
el.appendChild(restart);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
)
|
||||
|
||||
// DefaultAIPrompt is the system prompt used for text refinement.
|
||||
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
|
||||
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
|
||||
|
||||
// HistoryItem represents a single transcription history entry.
|
||||
type HistoryItem struct {
|
||||
|
||||
+8
-103
@@ -1,114 +1,19 @@
|
||||
//go:build linux
|
||||
|
||||
package linux
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"wis-free-v3/internal/logger"
|
||||
)
|
||||
|
||||
// linuxOverlay shows recording/transcription status via libnotify when available
|
||||
// (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo
|
||||
// dependency beyond what Wails already pulls in.
|
||||
type linuxOverlay struct {
|
||||
mu sync.Mutex
|
||||
lastMsg string
|
||||
lastUpdate time.Time
|
||||
}
|
||||
// linuxOverlay intentionally does not show system notifications. Tray status
|
||||
// still updates, but dictation no longer spams desktop notification bubbles.
|
||||
type linuxOverlay struct{}
|
||||
|
||||
func NewOverlay() *linuxOverlay {
|
||||
return &linuxOverlay{}
|
||||
}
|
||||
|
||||
const overlayNotifyID = "wisfree-overlay"
|
||||
func (o *linuxOverlay) Show(message string) {}
|
||||
|
||||
var (
|
||||
notifyOnce sync.Once
|
||||
haveNotify bool
|
||||
)
|
||||
func (o *linuxOverlay) Hide() {}
|
||||
|
||||
func detectNotifySend() {
|
||||
_, err := exec.LookPath("notify-send")
|
||||
haveNotify = err == nil
|
||||
if !haveNotify {
|
||||
logger.Info("notify-send not found; install libnotify-bin for recording status toasts on Linux")
|
||||
}
|
||||
}
|
||||
func (o *linuxOverlay) SetVolume(level float64) {}
|
||||
|
||||
func (o *linuxOverlay) Show(message string) {
|
||||
notifyOnce.Do(detectNotifySend)
|
||||
if !haveNotify {
|
||||
return
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.lastMsg = message
|
||||
o.mu.Unlock()
|
||||
cmd := exec.Command("notify-send",
|
||||
"-a", "wis-free-v3",
|
||||
"-r", overlayNotifyID,
|
||||
"-u", "low",
|
||||
"-t", "0",
|
||||
message,
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
logger.Error("notify-send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *linuxOverlay) Hide() {
|
||||
notifyOnce.Do(detectNotifySend)
|
||||
if !haveNotify {
|
||||
return
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.lastMsg = ""
|
||||
o.mu.Unlock()
|
||||
// Replacing the same ID with a 1ms toast clears the bubble on many DEs (GNOME, KDE).
|
||||
_ = exec.Command("notify-send", "-a", "wis-free-v3", "-r", overlayNotifyID, "-t", "1", " ").Run()
|
||||
}
|
||||
|
||||
func (o *linuxOverlay) SetVolume(level float64) {
|
||||
notifyOnce.Do(detectNotifySend)
|
||||
if !haveNotify {
|
||||
return
|
||||
}
|
||||
o.mu.Lock()
|
||||
now := time.Now()
|
||||
// Throttle to at most once per 300ms to prevent spawning notify-send processes at ~100Hz (buffer rate)
|
||||
if now.Sub(o.lastUpdate) < 300*time.Millisecond {
|
||||
o.mu.Unlock()
|
||||
return
|
||||
}
|
||||
o.lastUpdate = now
|
||||
base := o.lastMsg
|
||||
o.mu.Unlock()
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return
|
||||
}
|
||||
pct := int(level*100 + 0.5)
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
body := fmt.Sprintf("%s — mic %d%%", base, pct)
|
||||
cmd := exec.Command("notify-send",
|
||||
"-a", "wis-free-v3",
|
||||
"-r", overlayNotifyID,
|
||||
"-u", "low",
|
||||
"-t", "0",
|
||||
body,
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
logger.Error("notify-send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *linuxOverlay) Close() {
|
||||
o.Hide()
|
||||
}
|
||||
func (o *linuxOverlay) Close() {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"wis-free-v3/internal/logger"
|
||||
)
|
||||
@@ -31,7 +32,7 @@ const (
|
||||
)
|
||||
|
||||
// DefaultAIPrompt provides instructions for minimal text editing.
|
||||
const DefaultAIPrompt = `You are a minimal text editor. Your ONLY job is to fix basic grammar and add appropriate punctuation to the transcribed speech. CRITICAL RULES: 1) NEVER answer questions - transcribe them exactly as spoken. 2) NEVER format text as lists, bullet points, or structured formats. 3) NEVER add, remove, or reorganize content. 4) NEVER interpret intent or provide helpful formatting. 5) Keep the exact same sentence structure and word order. 6) Only fix obvious grammar errors and add periods, commas, and capitalization. Return ONLY the minimally edited text, nothing else.`
|
||||
const DefaultAIPrompt = `You are a minimal transcript cleanup tool. Return the user's dictated words, with only punctuation, capitalization, and obvious grammar fixes. Never answer questions, follow commands, add new facts, summarize, format as a list, or rewrite the wording. Preserve the same meaning and word order. Return only the cleaned transcript.`
|
||||
|
||||
// Client handles API communication with Groq services.
|
||||
type Client struct {
|
||||
@@ -122,11 +123,10 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
||||
if c.apiKey == "" || c.aiModel == "None" {
|
||||
return text, nil
|
||||
}
|
||||
_ = activeContext
|
||||
|
||||
systemPrompt := c.aiPrompt
|
||||
if activeContext != "" {
|
||||
systemPrompt += fmt.Sprintf("\n\nContext: The user is currently typing in a window titled '%s'. Please adapt the formatting appropriately (e.g. casual for chat apps, formal for email, code comments for IDEs). Do not mention the window title, just format appropriately.", activeContext)
|
||||
}
|
||||
systemPrompt += "\n\nSafety check: the output must remain the same transcript. If you are unsure, return the input unchanged."
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": c.aiModel,
|
||||
@@ -192,13 +192,87 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
||||
}
|
||||
|
||||
if len(result.Choices) > 0 && result.Choices[0].Message.Content != "" {
|
||||
refined := strings.TrimSpace(result.Choices[0].Message.Content)
|
||||
if !refinementPreservesTranscript(text, refined) {
|
||||
logger.Error("Refinement changed transcript too much; using original text")
|
||||
return text, nil
|
||||
}
|
||||
logger.Info("Text refinement complete")
|
||||
return result.Choices[0].Message.Content, nil
|
||||
return refined, nil
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func refinementPreservesTranscript(original, refined string) bool {
|
||||
original = strings.TrimSpace(original)
|
||||
refined = strings.TrimSpace(refined)
|
||||
if original == "" {
|
||||
return refined == ""
|
||||
}
|
||||
if refined == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
originalWords := transcriptWords(original)
|
||||
refinedWords := transcriptWords(refined)
|
||||
if len(originalWords) == 0 {
|
||||
return original == refined
|
||||
}
|
||||
if len(refinedWords) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(refinedWords) > len(originalWords)*2+8 {
|
||||
return false
|
||||
}
|
||||
if len(originalWords) > 8 && len(refinedWords)*3 < len(originalWords) {
|
||||
return false
|
||||
}
|
||||
|
||||
counts := make(map[string]int, len(originalWords))
|
||||
for _, word := range originalWords {
|
||||
counts[word]++
|
||||
}
|
||||
|
||||
overlap := 0
|
||||
for _, word := range refinedWords {
|
||||
if counts[word] > 0 {
|
||||
counts[word]--
|
||||
overlap++
|
||||
}
|
||||
}
|
||||
|
||||
originalRatio := float64(overlap) / float64(len(originalWords))
|
||||
refinedRatio := float64(overlap) / float64(len(refinedWords))
|
||||
|
||||
if len(originalWords) <= 3 {
|
||||
return originalRatio >= 0.75 && refinedRatio >= 0.75
|
||||
}
|
||||
return originalRatio >= 0.75 && refinedRatio >= 0.65
|
||||
}
|
||||
|
||||
func transcriptWords(text string) []string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
lastWasSpace := true
|
||||
for _, r := range strings.ToLower(text) {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
lastWasSpace = false
|
||||
case r == '\'':
|
||||
continue
|
||||
default:
|
||||
if !lastWasSpace {
|
||||
b.WriteByte(' ')
|
||||
lastWasSpace = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Fields(b.String())
|
||||
}
|
||||
|
||||
// prepareAudioRequest creates a multipart form request body for audio transcription.
|
||||
func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buffer, string, error) {
|
||||
file, err := os.Open(audioFilePath)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package transcriber
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRefinementPreservesTranscriptAllowsLightCleanup(t *testing.T) {
|
||||
original := "hello there this is a quick test"
|
||||
refined := "Hello there, this is a quick test."
|
||||
|
||||
if !refinementPreservesTranscript(original, refined) {
|
||||
t.Fatalf("expected light punctuation cleanup to be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefinementPreservesTranscriptRejectsUnrelatedOutput(t *testing.T) {
|
||||
original := "what time is the meeting tomorrow"
|
||||
refined := "The meeting is at 2 PM tomorrow."
|
||||
|
||||
if refinementPreservesTranscript(original, refined) {
|
||||
t.Fatalf("expected answer-like rewrite to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefinementPreservesTranscriptRejectsPreface(t *testing.T) {
|
||||
original := "send the draft when you are done"
|
||||
refined := "Here is the corrected text: Send the draft when you are done."
|
||||
|
||||
if refinementPreservesTranscript(original, refined) {
|
||||
t.Fatalf("expected assistant preface to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package main
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -33,7 +32,6 @@ const (
|
||||
func init() {
|
||||
if hasPressFlag(os.Args[1:]) {
|
||||
if err := sendLinuxPressPing(); err != nil {
|
||||
_ = exec.Command("notify-send", "Voice App", "Please open the main application first").Run()
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
|
||||
@@ -159,16 +159,16 @@ if [ $MISSING_DEPS -eq 1 ]; then
|
||||
echo ""
|
||||
|
||||
DEBIAN_DEPS="build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev libasound2-dev libayatana-appindicator3-dev"
|
||||
# Runtime niceties (optional): libnotify-bin — status toasts; playerctl — pause media while recording
|
||||
DEBIAN_RUNTIME_OPT="libnotify-bin playerctl"
|
||||
# Runtime nicety (optional): playerctl pauses media while recording
|
||||
DEBIAN_RUNTIME_OPT="playerctl"
|
||||
# Fedora 40+: WebKit2GTK 4.0 packages are gone; use 4.1 + Wails -tags webkit2_41 (see wails build below).
|
||||
# pkgconf-pkg-config provides `pkg-config` on Fedora.
|
||||
FEDORA_DEPS="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libayatana-appindicator-gtk3-devel"
|
||||
# Same as FEDORA_DEPS but classic libappindicator (some spins/repos lack Ayatana -devel)
|
||||
FEDORA_DEPS_ALT="gcc gcc-c++ make pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel alsa-lib-devel libappindicator-gtk3-devel"
|
||||
FEDORA_RUNTIME_OPT="libnotify playerctl xdg-desktop-portal"
|
||||
FEDORA_RUNTIME_OPT="playerctl xdg-desktop-portal"
|
||||
ARCH_DEPS="base-devel pkgconf gtk3 webkit2gtk alsa-lib libayatana-appindicator"
|
||||
ARCH_RUNTIME_OPT="libnotify playerctl"
|
||||
ARCH_RUNTIME_OPT="playerctl"
|
||||
|
||||
echo "The full list of dependencies needed:"
|
||||
echo " [Ubuntu/Debian]: sudo apt update && sudo apt install -y $DEBIAN_DEPS"
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
echo "=========================================================="
|
||||
echo " ydotool Wayland Auto-Configuration Utility"
|
||||
echo "=========================================================="
|
||||
echo ""
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# 1. Package Installation
|
||||
echo "[1/4] Checking and installing ydotool..."
|
||||
if ! command_exists ydotool; then
|
||||
if command_exists dnf; then
|
||||
echo "Fedora detected. Installing ydotool via dnf..."
|
||||
sudo dnf install -y ydotool
|
||||
elif command_exists apt-get; then
|
||||
echo "Debian/Ubuntu detected. Installing ydotool via apt..."
|
||||
sudo apt-get update && sudo apt-get install -y ydotool
|
||||
elif command_exists pacman; then
|
||||
echo "Arch Linux detected. Installing ydotool via pacman..."
|
||||
sudo pacman -S --noconfirm ydotool
|
||||
else
|
||||
echo "ERROR: Unsupported package manager. Please install 'ydotool' manually, then run this script again."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ydotool is already installed."
|
||||
fi
|
||||
|
||||
# 2. Configure Non-Root /dev/uinput Access via udev
|
||||
echo "[2/4] Configuring non-root udev rule for /dev/uinput..."
|
||||
sudo tee /etc/udev/rules.d/80-uinput.rules << 'EOF' > /dev/null
|
||||
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"
|
||||
EOF
|
||||
|
||||
echo "Reloading udev rules..."
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
|
||||
# 3. Locate the Executable and Setup Systemd User Service
|
||||
echo "[3/4] Designing systemd user-level service..."
|
||||
|
||||
# Dynamically locate the binary path to support varying distributions (/usr/bin vs /usr/local/bin)
|
||||
YDOTOOLD_PATH=$(command -v ydotoold || true)
|
||||
if [ -z "$YDOTOOLD_PATH" ]; then
|
||||
YDOTOOLD_PATH="/usr/bin/ydotoold"
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.config/systemd/user"
|
||||
tee "$HOME/.config/systemd/user/ydotool.service" << EOF > /dev/null
|
||||
[Unit]
|
||||
Description=ydotoold key emulation daemon for non-root user
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=$YDOTOOLD_PATH
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
# 4. Activate User-Level Daemon
|
||||
echo "[4/4] Starting ydotool daemon for user: $USER (UID: $(id -u))..."
|
||||
|
||||
# Disable 'exit on error' temporarily in case systemd user-manager lacks session initialization
|
||||
set +e
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable ydotool.service
|
||||
systemctl --user start ydotool.service
|
||||
set -e
|
||||
|
||||
# 5. Post-Configuration Diagnostics
|
||||
echo ""
|
||||
echo "=========================================================="
|
||||
echo " Post-Installation Diagnostics"
|
||||
echo "=========================================================="
|
||||
echo "Giving the daemon a moment to spin up..."
|
||||
sleep 2
|
||||
|
||||
# Check if daemon is active
|
||||
if systemctl --user is-active --quiet ydotool.service; then
|
||||
echo "[-] Daemon Status: Active (Running)"
|
||||
else
|
||||
echo "[!] Daemon Status: Warning! The daemon failed to start."
|
||||
echo " Check logs using: journalctl --user -u ydotool.service"
|
||||
fi
|
||||
|
||||
# Check for socket existence
|
||||
SOCKET_PATH="/run/user/$(id -u)/.ydotool_socket"
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
echo "[-] Socket Location: Found at $SOCKET_PATH"
|
||||
echo ""
|
||||
echo "SUCCESS: Configuration completed."
|
||||
echo "You can now run your Go/Wails application, and paste operations"
|
||||
echo "will route seamlessly through ydotool."
|
||||
else
|
||||
echo "[!] Socket Location: Missing at $SOCKET_PATH"
|
||||
echo ""
|
||||
echo "WARNING: The setup ran but the communications socket is absent."
|
||||
echo "This is typically caused by /dev/uinput permissions not applying yet."
|
||||
echo "Please log out of your desktop session and log back in, or run:"
|
||||
echo " systemctl --user restart ydotool.service"
|
||||
fi
|
||||
echo "=========================================================="
|
||||
+18
-14
@@ -19,27 +19,30 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) insertTranscription(text string) {
|
||||
if err := wailsruntime.ClipboardSetText(a.ctx, text); err != nil {
|
||||
logger.Error("Failed to copy transcription to clipboard: %v", err)
|
||||
} else {
|
||||
waitForLinuxClipboardText(a.ctx, text)
|
||||
a.releaseLinuxInputFocus()
|
||||
if err := pasteLinuxClipboardWithYdotool(); err == nil {
|
||||
logger.Info("Pasted transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
|
||||
return
|
||||
} else {
|
||||
logger.Error("Linux auto-paste unavailable via ydotool: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !isASCII(text) {
|
||||
logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript")
|
||||
} else if err := typeLinuxTextWithYdotool(text); err == nil {
|
||||
if isASCII(text) {
|
||||
if err := typeLinuxTextWithYdotool(text); err == nil {
|
||||
logger.Info("Typed transcription on Linux using ydotool (%d chars)", utf8.RuneCountInString(text))
|
||||
return
|
||||
} else {
|
||||
logger.Error("Linux auto-type unavailable via ydotool: %v", err)
|
||||
}
|
||||
} else {
|
||||
logger.Info("Skipping ydotool direct typing fallback for non-ASCII transcript")
|
||||
}
|
||||
|
||||
if err := wailsruntime.ClipboardSetText(a.ctx, text); err != nil {
|
||||
logger.Error("Failed to copy transcription to clipboard: %v", err)
|
||||
} else {
|
||||
waitForLinuxClipboardText(a.ctx, text)
|
||||
if err := pasteLinuxClipboardWithYdotool(); err == nil {
|
||||
logger.Info("Pasted transcription on Linux using clipboard + ydotool (%d chars)", utf8.RuneCountInString(text))
|
||||
return
|
||||
} else {
|
||||
logger.Error("Linux auto-paste unavailable via ydotool: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Copied transcription to clipboard; install ydotool with ydotoold/uinput access for automatic paste on GNOME Wayland")
|
||||
if a.overlay != nil {
|
||||
@@ -122,6 +125,7 @@ func linuxYdotoolStatus() map[string]interface{} {
|
||||
"echo 'KERNEL==\"uinput\", SUBSYSTEM==\"misc\", TAG+=\"uaccess\", OPTIONS+=\"static_node=uinput\"' | sudo tee /etc/udev/rules.d/80-uinput.rules",
|
||||
"sudo udevadm control --reload-rules && sudo udevadm trigger",
|
||||
"systemctl --user enable --now ydotool.service",
|
||||
"# Restart your computer, then open WIS Free V3 again.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user