native first

This commit is contained in:
Илья Глазунов
2026-09-05 02:20:11 +03:00
parent 219f35cc04
commit 8beccdf101
40 changed files with 3495 additions and 327 deletions
+1 -1
View File
@@ -612,7 +612,7 @@ export class CheatingDaddyApp extends LitElement {
async handleStart() {
const prefs = await cheatingDaddy.storage.getPreferences();
const providerMode = prefs.providerMode || "byok";
const providerMode = prefs.providerMode || "local";
if (providerMode === "local") {
const success = await cheatingDaddy.initializeLocal(this.selectedProfile);
+151 -134
View File
@@ -1,143 +1,160 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
import { unifiedPageStyles } from './sharedPageStyles.js';
import { html, css, LitElement } from "../../assets/lit-core-2.7.4.min.js";
import { unifiedPageStyles } from "./sharedPageStyles.js";
export class AICustomizeView extends LitElement {
static styles = [
unifiedPageStyles,
css`
.unified-page {
height: 100%;
}
.unified-wrap {
height: 100%;
}
section.surface {
flex: 1;
display: flex;
flex-direction: column;
}
.form-grid {
flex: 1;
display: flex;
flex-direction: column;
}
.form-group.vertical {
flex: 1;
display: flex;
flex-direction: column;
}
textarea.control {
flex: 1;
resize: none;
overflow-y: auto;
min-height: 0;
}
`,
static styles = [
unifiedPageStyles,
css`
.unified-page {
height: 100%;
}
.unified-wrap {
height: 100%;
}
section.surface {
flex: 1;
display: flex;
flex-direction: column;
}
.form-grid {
flex: 1;
display: flex;
flex-direction: column;
}
.form-group.vertical {
flex: 1;
display: flex;
flex-direction: column;
}
textarea.control {
flex: 1;
resize: none;
overflow-y: auto;
min-height: 0;
}
`,
];
static properties = {
selectedProfile: { type: String },
onProfileChange: { type: Function },
_context: { state: true },
_providerMode: { state: true },
};
constructor() {
super();
this.selectedProfile = "interview";
this.onProfileChange = () => {};
this._context = "";
this._providerMode = "local";
this._loadFromStorage();
}
async _loadFromStorage() {
try {
const prefs = await cheatingDaddy.storage.getPreferences();
this._context = prefs.customPrompt || "";
this._providerMode = prefs.providerMode || "local";
this.requestUpdate();
} catch (error) {
console.error("Error loading AI customize storage:", error);
}
}
_handleProfileChange(e) {
this.onProfileChange(e.target.value);
}
async _handleProviderModeChange(e) {
this._providerMode = e.target.value;
await cheatingDaddy.storage.updatePreference(
"providerMode",
this._providerMode,
);
this.requestUpdate();
}
async _saveContext(val) {
this._context = val;
await cheatingDaddy.storage.updatePreference("customPrompt", val);
}
_getProfileName(profile) {
const names = {
interview: "Job Interview",
sales: "Sales Call",
meeting: "Business Meeting",
presentation: "Presentation",
negotiation: "Negotiation",
exam: "Exam Assistant",
};
return names[profile] || profile;
}
render() {
const profiles = [
{ value: "interview", label: "Job Interview" },
{ value: "sales", label: "Sales Call" },
{ value: "meeting", label: "Business Meeting" },
{ value: "presentation", label: "Presentation" },
{ value: "negotiation", label: "Negotiation" },
{ value: "exam", label: "Exam Assistant" },
];
static properties = {
selectedProfile: { type: String },
onProfileChange: { type: Function },
_context: { state: true },
_providerMode: { state: true },
};
constructor() {
super();
this.selectedProfile = 'interview';
this.onProfileChange = () => {};
this._context = '';
this._providerMode = 'byok';
this._loadFromStorage();
}
async _loadFromStorage() {
try {
const prefs = await cheatingDaddy.storage.getPreferences();
this._context = prefs.customPrompt || '';
this._providerMode = prefs.providerMode || 'byok';
this.requestUpdate();
} catch (error) {
console.error('Error loading AI customize storage:', error);
}
}
_handleProfileChange(e) {
this.onProfileChange(e.target.value);
}
async _handleProviderModeChange(e) {
this._providerMode = e.target.value;
await cheatingDaddy.storage.updatePreference('providerMode', this._providerMode);
this.requestUpdate();
}
async _saveContext(val) {
this._context = val;
await cheatingDaddy.storage.updatePreference('customPrompt', val);
}
_getProfileName(profile) {
const names = {
interview: 'Job Interview',
sales: 'Sales Call',
meeting: 'Business Meeting',
presentation: 'Presentation',
negotiation: 'Negotiation',
exam: 'Exam Assistant',
};
return names[profile] || profile;
}
render() {
const profiles = [
{ value: 'interview', label: 'Job Interview' },
{ value: 'sales', label: 'Sales Call' },
{ value: 'meeting', label: 'Business Meeting' },
{ value: 'presentation', label: 'Presentation' },
{ value: 'negotiation', label: 'Negotiation' },
{ value: 'exam', label: 'Exam Assistant' },
];
return html`
<div class="unified-page">
<div class="unified-wrap">
<div>
<div class="page-title">AI Context</div>
</div>
<section class="surface">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Regime</label>
<select class="control" .value=${this._providerMode} @change=${this._handleProviderModeChange}>
<option value="byok">BYOK (API Keys)</option>
<option value="local">Local AI (Ollama)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Profile</label>
<select class="control" .value=${this.selectedProfile} @change=${this._handleProfileChange}>
${profiles.map(profile => html`<option value=${profile.value}>${profile.label}</option>`)}
</select>
</div>
<div class="form-group vertical">
<label class="form-label">Custom Instructions</label>
<textarea
class="control"
placeholder="Resume details, role requirements, constraints..."
.value=${this._context}
@input=${e => this._saveContext(e.target.value)}
></textarea>
<div class="form-help">Sent as context at session start. Keep it short.</div>
</div>
</div>
</section>
return html`
<div class="unified-page">
<div class="unified-wrap">
<div>
<div class="page-title">AI Context</div>
</div>
<section class="surface">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Regime</label>
<select
class="control"
.value=${this._providerMode}
@change=${this._handleProviderModeChange}
>
<option value="byok">BYOK (API Keys)</option>
<option value="local">Local AI (LM Studio)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Profile</label>
<select
class="control"
.value=${this.selectedProfile}
@change=${this._handleProfileChange}
>
${profiles.map(
(profile) =>
html`<option value=${profile.value}>
${profile.label}
</option>`,
)}
</select>
</div>
<div class="form-group vertical">
<label class="form-label">Custom Instructions</label>
<textarea
class="control"
placeholder="Resume details, role requirements, constraints..."
.value=${this._context}
@input=${(e) => this._saveContext(e.target.value)}
></textarea>
<div class="form-help">
Sent as context at session start. Keep it short.
</div>
</div>
</div>
`;
}
</section>
</div>
</div>
`;
}
}
customElements.define('ai-customize-view', AICustomizeView);
customElements.define("ai-customize-view", AICustomizeView);
+3 -3
View File
@@ -208,7 +208,7 @@ export class CustomizeView extends LitElement {
this.onImageQualityChange = () => {};
this.onLayoutModeChange = () => {};
this.googleSearchEnabled = true;
this.providerMode = "byok";
this.providerMode = "local";
this.isClearing = false;
this.isRestoring = false;
this.clearStatusMessage = "";
@@ -232,7 +232,7 @@ export class CustomizeView extends LitElement {
cheatingDaddy.storage.getKeybinds(),
]);
this.googleSearchEnabled = prefs.googleSearchEnabled ?? true;
this.providerMode = prefs.providerMode || "byok";
this.providerMode = prefs.providerMode || "local";
this.backgroundTransparency = prefs.backgroundTransparency ?? 0.8;
this.fontSize = prefs.fontSize ?? 20;
this.audioMode = prefs.audioMode ?? "speaker_only";
@@ -664,7 +664,7 @@ export class CustomizeView extends LitElement {
@change=${this.handleProviderModeChange}
>
<option value="byok">BYOK (API Keys)</option>
<option value="local">Local AI (Ollama)</option>
<option value="local">Local AI (LM Studio)</option>
</select>
</div>
</div>
+165 -138
View File
@@ -501,6 +501,11 @@ export class MainView extends LitElement {
_whisperModel: { state: true },
_customWhisperModel: { state: true },
_showLocalHelp: { state: true },
_localLlmBaseUrl: { state: true },
_localLlmModel: { state: true },
_localLlmApiKey: { state: true },
_localSttUrl: { state: true },
_localSttLanguage: { state: true },
};
constructor() {
@@ -513,7 +518,7 @@ export class MainView extends LitElement {
this.whisperDownloading = false;
this.whisperProgress = null;
this._mode = "byok";
this._mode = "local";
this._token = "";
this._geminiKey = "";
this._groqKey = "";
@@ -528,6 +533,11 @@ export class MainView extends LitElement {
this._tokenError = false;
this._keyError = false;
this._showLocalHelp = false;
this._localLlmBaseUrl = "http://127.0.0.1:1234/v1";
this._localLlmModel = "";
this._localLlmApiKey = "";
this._localSttUrl = "ws://127.0.0.1:8765/v1/asr/stream";
this._localSttLanguage = "en-US";
this._ollamaHost = "http://127.0.0.1:11434";
this._ollamaModel = "llama3.1";
this._whisperModel = "Xenova/whisper-small";
@@ -549,7 +559,7 @@ export class MainView extends LitElement {
cheatingDaddy.storage.getCredentials().catch(() => ({})),
]);
this._mode = prefs.providerMode || "byok";
this._mode = prefs.providerMode || "local";
// Load keys
this._token = "";
@@ -571,6 +581,13 @@ export class MainView extends LitElement {
this._responseProvider = prefs.responseProvider || "gemini";
// Load local AI settings
this._localLlmBaseUrl =
prefs.localLlmBaseUrl || "http://127.0.0.1:1234/v1";
this._localLlmModel = prefs.localLlmModel || "";
this._localLlmApiKey = prefs.localLlmApiKey || "";
this._localSttUrl =
prefs.localSttUrl || "ws://127.0.0.1:8765/v1/asr/stream";
this._localSttLanguage = prefs.localSttLanguage || "en-US";
this._ollamaHost = prefs.ollamaHost || "http://127.0.0.1:11434";
this._ollamaModel = prefs.ollamaModel || "llama3.1";
this._whisperModel = prefs.whisperModel || "Xenova/whisper-small";
@@ -917,6 +934,51 @@ export class MainView extends LitElement {
this.requestUpdate();
}
async _saveLocalLlmBaseUrl(val) {
this._localLlmBaseUrl = val;
await cheatingDaddy.storage.updatePreference("localLlmBaseUrl", val);
this.requestUpdate();
}
async _saveLocalLlmModel(val) {
this._localLlmModel = val;
await cheatingDaddy.storage.updatePreference("localLlmModel", val);
this.requestUpdate();
}
async _saveLocalLlmApiKey(val) {
this._localLlmApiKey = val;
await cheatingDaddy.storage.updatePreference("localLlmApiKey", val);
this.requestUpdate();
}
async _saveLocalSttUrl(val) {
this._localSttUrl = val;
await cheatingDaddy.storage.updatePreference("localSttUrl", val);
this.requestUpdate();
}
async _saveLocalSttLanguage(val) {
this._localSttLanguage = val;
await cheatingDaddy.storage.updatePreference("localSttLanguage", val);
this.requestUpdate();
}
_isLoopbackUrl(value) {
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch (_) {
return false;
}
}
async _saveOllamaHost(val) {
this._ollamaHost = val;
await cheatingDaddy.storage.updatePreference("ollamaHost", val);
@@ -1002,8 +1064,13 @@ export class MainView extends LitElement {
return;
}
} else if (this._mode === "local") {
// Local mode doesn't need API keys, just Ollama host
if (!this._ollamaHost.trim()) {
if (
!this._localLlmBaseUrl.trim() ||
!this._localLlmModel.trim() ||
!this._localSttUrl.trim()
) {
this._keyError = true;
this.requestUpdate();
return;
}
}
@@ -1258,105 +1325,88 @@ export class MainView extends LitElement {
// ── Local AI mode ──
_renderLocalMode() {
const llmIsLocal = this._isLoopbackUrl(this._localLlmBaseUrl);
const sttIsLocal = this._isLoopbackUrl(this._localSttUrl);
return html`
<div class="form-group">
<label class="form-label">Ollama Host</label>
<label class="form-label">LM Studio Base URL</label>
<input
type="text"
placeholder="http://127.0.0.1:11434"
.value=${this._ollamaHost}
@input=${(e) => this._saveOllamaHost(e.target.value)}
/>
<div class="form-hint">Ollama must be running locally</div>
</div>
<div class="form-group">
<label class="form-label">Ollama Model</label>
<input
type="text"
placeholder="llama3.1"
.value=${this._ollamaModel}
@input=${(e) => this._saveOllamaModel(e.target.value)}
placeholder="http://127.0.0.1:1234/v1"
.value=${this._localLlmBaseUrl}
@input=${(e) => this._saveLocalLlmBaseUrl(e.target.value)}
class=${this._keyError && !this._localLlmBaseUrl.trim()
? "error"
: ""}
/>
<div class="form-hint">
Run
<code
style="font-family: var(--font-mono); font-size: 11px; background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;"
>ollama pull ${this._ollamaModel}</code
>
first
LM Studio local server endpoint for OpenAI-compatible chat
${!llmIsLocal
? html`<span style="color: var(--warning, #d97706);">
· not a localhost URL
</span>`
: ""}
</div>
</div>
<div class="form-group">
<div class="whisper-label-row">
<label class="form-label">Whisper Model</label>
${this.whisperDownloading
? html`<div class="whisper-spinner"></div>`
<label class="form-label">LM Studio Model ID</label>
<input
type="text"
placeholder="gemma-4 or the exact loaded model id"
.value=${this._localLlmModel}
@input=${(e) => this._saveLocalLlmModel(e.target.value)}
class=${this._keyError && !this._localLlmModel.trim() ? "error" : ""}
/>
<div class="form-hint">
Manual only: use the model identifier shown in LM Studio
</div>
</div>
<div class="form-group">
<label class="form-label">LM Studio API Key</label>
<input
type="password"
placeholder="Optional for most local LM Studio setups"
.value=${this._localLlmApiKey}
@input=${(e) => this._saveLocalLlmApiKey(e.target.value)}
/>
<div class="form-hint">
Leave blank unless your local server requires a token
</div>
</div>
<div class="form-group">
<label class="form-label">Nemotron ASR Sidecar URL</label>
<input
type="text"
placeholder="ws://127.0.0.1:8765/v1/asr/stream"
.value=${this._localSttUrl}
@input=${(e) => this._saveLocalSttUrl(e.target.value)}
class=${this._keyError && !this._localSttUrl.trim() ? "error" : ""}
/>
<div class="form-hint">
External streaming STT service that accepts 16 kHz mono PCM
${!sttIsLocal
? html`<span style="color: var(--warning, #d97706);">
· not a localhost URL
</span>`
: ""}
</div>
<select
.value=${this._whisperModel}
@change=${(e) => this._saveWhisperModel(e.target.value)}
>
<option
value="Xenova/whisper-tiny"
?selected=${this._whisperModel === "Xenova/whisper-tiny"}
>
Tiny (fastest, least accurate)
</option>
<option
value="Xenova/whisper-base"
?selected=${this._whisperModel === "Xenova/whisper-base"}
>
Base
</option>
<option
value="Xenova/whisper-small"
?selected=${this._whisperModel === "Xenova/whisper-small"}
>
Small (recommended)
</option>
<option
value="Xenova/whisper-medium"
?selected=${this._whisperModel === "Xenova/whisper-medium"}
>
Medium (most accurate, slowest)
</option>
<option
value="__custom__"
?selected=${this._whisperModel === "__custom__"}
>
Custom HuggingFace model...
</option>
</select>
${this._whisperModel === "__custom__"
? html`
<input
type="text"
placeholder="e.g. onnx-community/whisper-large-v3-turbo"
.value=${this._customWhisperModel}
@change=${(e) => this._saveCustomWhisperModel(e.target.value)}
@input=${(e) => {
this._customWhisperModel = e.target.value;
}}
style="margin-top: 6px;"
/>
<div class="form-hint">
Enter a HuggingFace model ID compatible with
@huggingface/transformers speech-to-text pipeline
</div>
`
: html`
<div class="form-hint">
${this.whisperDownloading
? "Downloading model..."
: "Downloaded automatically on first use"}
</div>
`}
${this.whisperDownloading && this.whisperProgress
? this._renderWhisperProgress()
: ""}
</div>
<div class="form-group">
<label class="form-label">STT Language</label>
<input
type="text"
placeholder="en-US"
.value=${this._localSttLanguage}
@input=${(e) => this._saveLocalSttLanguage(e.target.value)}
/>
<div class="form-hint">
First local sidecar target is English streaming ASR
</div>
</div>
${this._renderStartButton()}
@@ -1458,74 +1508,51 @@ export class MainView extends LitElement {
return html`
<div class="help-content">
<div class="help-section">
<div class="help-section-title">What is Ollama?</div>
<div class="help-section-title">LM Studio</div>
<div class="help-section-text">
Ollama lets you run large language models locally on your machine.
Everything stays on your computer — no data leaves your device.
LM Studio runs the answer model locally and exposes an
OpenAI-compatible server for streaming responses.
</div>
</div>
<div class="help-section">
<div class="help-section-title">Install Ollama</div>
<div class="help-section-title">Start LM Studio server</div>
<div class="help-section-text">
Download from
<span
class="help-link"
@click=${() => this.onExternalLink("https://ollama.com/download")}
>ollama.com/download</span
>
and install it.
Download LM Studio, load a model, then start the local server from
the Developer tab. The default endpoint is:
</div>
<code class="help-code">http://127.0.0.1:1234/v1</code>
</div>
<div class="help-section">
<div class="help-section-title">Model ID</div>
<div class="help-section-text">
Enter the exact model identifier shown by LM Studio. Gemma 4 is the
reference target, but any loaded compatible model can be used.
</div>
</div>
<div class="help-section">
<div class="help-section-title">Ollama must be running</div>
<div class="help-section-title">Nemotron ASR sidecar</div>
<div class="help-section-text">
Ollama needs to be running before you start a session. If it's not
running, open your terminal and type:
Speech-to-text runs as a separate local streaming service. The app
connects to:
</div>
<code class="help-code">ollama serve</code>
<code class="help-code">ws://127.0.0.1:8765/v1/asr/stream</code>
</div>
<div class="help-section">
<div class="help-section-title">Pull a model</div>
<div class="help-section-title">Screenshots</div>
<div class="help-section-text">
Download a model before first use:
</div>
<code class="help-code">ollama pull gemma3:4b</code>
</div>
<div class="help-section">
<div class="help-section-title">Recommended models</div>
<div class="help-models">
<div class="help-model">
<span class="help-model-name">gemma3:4b</span
><span>4B — fast, multimodal (images + text)</span>
</div>
<div class="help-model">
<span class="help-model-name">mistral-small</span
><span>8B — solid all-rounder, text only</span>
</div>
</div>
<div class="help-section-text">
gemma3:4b and above supports images — screenshots will work with
these models.
Manual screenshots are sent to the same local LM Studio model. Use a
vision-capable model for screen analysis.
</div>
</div>
<div class="help-section">
<div class="help-warn">
Avoid "thinking" models (e.g. deepseek-r1, qwq). Local inference is
already slower — a thinking model adds extra delay before
responding.
</div>
</div>
<div class="help-section">
<div class="help-section-title">Whisper</div>
<div class="help-section-text">
The Whisper speech-to-text model is downloaded automatically the
first time you start a session. This is a one-time download.
Non-local endpoints are allowed, but they may send audio transcripts
or screenshots outside this machine.
</div>
</div>
</div>