1537 lines
42 KiB
JavaScript
1537 lines
42 KiB
JavaScript
import { html, css, LitElement } from "../../assets/lit-core-2.7.4.min.js";
|
|
|
|
export class MainView extends LitElement {
|
|
static styles = css`
|
|
* {
|
|
font-family: var(--font);
|
|
cursor: default;
|
|
user-select: none;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:host {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: var(--space-xl) var(--space-lg);
|
|
}
|
|
|
|
.form-wrapper {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
}
|
|
|
|
.page-title {
|
|
font-size: var(--font-size-xl);
|
|
font-weight: var(--font-weight-semibold);
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--space-xs);
|
|
}
|
|
|
|
.page-title .mode-suffix {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.page-subtitle {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-muted);
|
|
margin-bottom: var(--space-md);
|
|
}
|
|
|
|
/* ── Form controls ── */
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-xs);
|
|
}
|
|
|
|
.form-label {
|
|
font-size: var(--font-size-xs);
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
input,
|
|
select,
|
|
textarea {
|
|
background: var(--bg-elevated);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
padding: 10px 12px;
|
|
width: 100%;
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--font-size-sm);
|
|
font-family: var(--font);
|
|
transition:
|
|
border-color var(--transition),
|
|
box-shadow var(--transition);
|
|
}
|
|
|
|
input:hover:not(:focus),
|
|
select:hover:not(:focus),
|
|
textarea:hover:not(:focus) {
|
|
border-color: var(--text-muted);
|
|
}
|
|
|
|
input:focus,
|
|
select:focus,
|
|
textarea:focus {
|
|
outline: none;
|
|
border-color: var(--accent);
|
|
box-shadow: 0 0 0 1px var(--accent);
|
|
}
|
|
|
|
input::placeholder,
|
|
textarea::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
input.error {
|
|
border-color: var(--danger, #ef4444);
|
|
}
|
|
|
|
select {
|
|
cursor: pointer;
|
|
appearance: none;
|
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
|
background-position: right 8px center;
|
|
background-repeat: no-repeat;
|
|
background-size: 14px;
|
|
padding-right: 28px;
|
|
}
|
|
|
|
textarea {
|
|
resize: vertical;
|
|
min-height: 80px;
|
|
line-height: var(--line-height);
|
|
}
|
|
|
|
.form-hint {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.form-hint a,
|
|
.form-hint span.link {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form-hint span.link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.whisper-label-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.whisper-spinner {
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: whisper-spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes whisper-spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* ── Whisper download progress ── */
|
|
|
|
.whisper-progress-container {
|
|
margin-top: 8px;
|
|
padding: 8px 10px;
|
|
background: var(--bg-elevated, rgba(255, 255, 255, 0.05));
|
|
border-radius: var(--radius-sm, 6px);
|
|
border: 1px solid var(--border, rgba(255, 255, 255, 0.1));
|
|
}
|
|
|
|
.whisper-progress-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
font-size: 11px;
|
|
color: var(--text-secondary, #999);
|
|
}
|
|
|
|
.whisper-progress-file {
|
|
font-family: var(--font-mono, monospace);
|
|
font-size: 10px;
|
|
color: var(--text-secondary, #999);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.whisper-progress-pct {
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 600;
|
|
color: var(--accent, #6cb4ee);
|
|
}
|
|
|
|
.whisper-progress-track {
|
|
height: 4px;
|
|
background: var(--border, rgba(255, 255, 255, 0.1));
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.whisper-progress-bar {
|
|
height: 100%;
|
|
background: var(--accent, #6cb4ee);
|
|
border-radius: 2px;
|
|
transition: width 0.3s ease;
|
|
min-width: 0;
|
|
}
|
|
|
|
.whisper-progress-size {
|
|
margin-top: 4px;
|
|
font-size: 10px;
|
|
color: var(--text-tertiary, #666);
|
|
text-align: right;
|
|
}
|
|
|
|
/* ── Start button ── */
|
|
|
|
.start-button {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: #e8e8e8;
|
|
color: #111111;
|
|
border: none;
|
|
padding: 12px var(--space-md);
|
|
border-radius: var(--radius-sm);
|
|
font-size: var(--font-size-base);
|
|
font-weight: var(--font-weight-semibold);
|
|
cursor: pointer;
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.start-button canvas.btn-aurora {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 0;
|
|
}
|
|
|
|
.start-button canvas.btn-dither {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 1;
|
|
opacity: 0.1;
|
|
mix-blend-mode: overlay;
|
|
pointer-events: none;
|
|
image-rendering: pixelated;
|
|
}
|
|
|
|
.start-button .btn-label {
|
|
position: relative;
|
|
z-index: 2;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.start-button:hover {
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.start-button.disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.start-button.disabled:hover {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.shortcut-hint {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
opacity: 0.5;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
/* ── Divider ── */
|
|
|
|
.divider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-md);
|
|
margin: var(--space-sm) 0;
|
|
}
|
|
|
|
.divider-line {
|
|
flex: 1;
|
|
height: 1px;
|
|
background: var(--border);
|
|
}
|
|
|
|
.divider-text {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-muted);
|
|
text-transform: lowercase;
|
|
}
|
|
|
|
/* ── Mode switch links ── */
|
|
|
|
.mode-links {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: var(--space-lg);
|
|
}
|
|
|
|
.mode-link {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
transition: color var(--transition);
|
|
}
|
|
|
|
.mode-link:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* ── Mode option cards ── */
|
|
|
|
.mode-cards {
|
|
display: flex;
|
|
gap: var(--space-sm);
|
|
}
|
|
|
|
.mode-card {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 12px 14px;
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border);
|
|
background: var(--bg-elevated);
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.2s,
|
|
background 0.2s;
|
|
}
|
|
|
|
.mode-card:hover {
|
|
border-color: var(--text-muted);
|
|
background: var(--bg-hover);
|
|
}
|
|
|
|
.mode-card-title {
|
|
font-size: var(--font-size-sm);
|
|
font-weight: var(--font-weight-semibold);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.mode-card-desc {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-muted);
|
|
line-height: var(--line-height);
|
|
}
|
|
|
|
/* ── Title row with help ── */
|
|
|
|
.title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: var(--space-xs);
|
|
}
|
|
|
|
.title-row .page-title {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.help-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: var(--radius-sm);
|
|
transition: color 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.help-btn:hover {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.help-btn * {
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* ── Help content ── */
|
|
|
|
.help-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-md);
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.help-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.help-section-title {
|
|
font-size: var(--font-size-xs);
|
|
font-weight: var(--font-weight-semibold);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.help-section-text {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-secondary);
|
|
line-height: var(--line-height);
|
|
}
|
|
|
|
.help-code {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
background: var(--bg-hover);
|
|
padding: 6px 8px;
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-primary);
|
|
display: block;
|
|
}
|
|
|
|
.help-link {
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.help-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.help-models {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.help-model {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.help-model-name {
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.help-divider {
|
|
border: none;
|
|
border-top: 1px solid var(--border);
|
|
margin: 0;
|
|
}
|
|
|
|
.help-warn {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--warning);
|
|
line-height: var(--line-height);
|
|
}
|
|
`;
|
|
|
|
static properties = {
|
|
onStart: { type: Function },
|
|
onExternalLink: { type: Function },
|
|
selectedProfile: { type: String },
|
|
onProfileChange: { type: Function },
|
|
isInitializing: { type: Boolean },
|
|
whisperDownloading: { type: Boolean },
|
|
whisperProgress: { type: Object },
|
|
// Internal state
|
|
_mode: { state: true },
|
|
_token: { state: true },
|
|
_geminiKey: { state: true },
|
|
_groqKey: { state: true },
|
|
_openaiKey: { state: true },
|
|
_openaiCompatibleApiKey: { state: true },
|
|
_openaiCompatibleBaseUrl: { state: true },
|
|
_openaiCompatibleModel: { state: true },
|
|
_availableModels: { state: true },
|
|
_loadingModels: { state: true },
|
|
_manualModelInput: { state: true },
|
|
_responseProvider: { state: true },
|
|
_tokenError: { state: true },
|
|
_keyError: { state: true },
|
|
// Local AI state
|
|
_ollamaHost: { state: true },
|
|
_ollamaModel: { state: true },
|
|
_whisperModel: { state: true },
|
|
_customWhisperModel: { state: true },
|
|
_showLocalHelp: { state: true },
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this.onStart = () => {};
|
|
this.onExternalLink = () => {};
|
|
this.selectedProfile = "interview";
|
|
this.onProfileChange = () => {};
|
|
this.isInitializing = false;
|
|
this.whisperDownloading = false;
|
|
this.whisperProgress = null;
|
|
|
|
this._mode = "byok";
|
|
this._token = "";
|
|
this._geminiKey = "";
|
|
this._groqKey = "";
|
|
this._openaiKey = "";
|
|
this._openaiCompatibleApiKey = "";
|
|
this._openaiCompatibleBaseUrl = "";
|
|
this._openaiCompatibleModel = "";
|
|
this._availableModels = [];
|
|
this._loadingModels = false;
|
|
this._manualModelInput = false;
|
|
this._responseProvider = "gemini";
|
|
this._tokenError = false;
|
|
this._keyError = false;
|
|
this._showLocalHelp = false;
|
|
this._ollamaHost = "http://127.0.0.1:11434";
|
|
this._ollamaModel = "llama3.1";
|
|
this._whisperModel = "Xenova/whisper-small";
|
|
this._customWhisperModel = "";
|
|
|
|
this._animId = null;
|
|
this._time = 0;
|
|
this._mouseX = -1;
|
|
this._mouseY = -1;
|
|
|
|
this.boundKeydownHandler = this._handleKeydown.bind(this);
|
|
this._loadFromStorage();
|
|
}
|
|
|
|
async _loadFromStorage() {
|
|
try {
|
|
const [prefs, creds] = await Promise.all([
|
|
cheatingDaddy.storage.getPreferences(),
|
|
cheatingDaddy.storage.getCredentials().catch(() => ({})),
|
|
]);
|
|
|
|
this._mode = prefs.providerMode || "byok";
|
|
|
|
// Load keys
|
|
this._token = "";
|
|
this._geminiKey =
|
|
(await cheatingDaddy.storage.getApiKey().catch(() => "")) || "";
|
|
this._groqKey =
|
|
(await cheatingDaddy.storage.getGroqApiKey().catch(() => "")) || "";
|
|
this._openaiKey = creds.openaiKey || "";
|
|
|
|
// Load OpenAI-compatible config
|
|
const openaiConfig = await cheatingDaddy.storage
|
|
.getOpenAICompatibleConfig()
|
|
.catch(() => ({}));
|
|
this._openaiCompatibleApiKey = openaiConfig.apiKey || "";
|
|
this._openaiCompatibleBaseUrl = openaiConfig.baseUrl || "";
|
|
this._openaiCompatibleModel = openaiConfig.model || "";
|
|
|
|
// Load response provider preference
|
|
this._responseProvider = prefs.responseProvider || "gemini";
|
|
|
|
// Load local AI settings
|
|
this._ollamaHost = prefs.ollamaHost || "http://127.0.0.1:11434";
|
|
this._ollamaModel = prefs.ollamaModel || "llama3.1";
|
|
this._whisperModel = prefs.whisperModel || "Xenova/whisper-small";
|
|
// If the saved model isn't one of the presets, it's a custom HF model
|
|
const presets = [
|
|
"Xenova/whisper-tiny",
|
|
"Xenova/whisper-base",
|
|
"Xenova/whisper-small",
|
|
"Xenova/whisper-medium",
|
|
];
|
|
if (!presets.includes(this._whisperModel)) {
|
|
this._customWhisperModel = this._whisperModel;
|
|
this._whisperModel = "__custom__";
|
|
}
|
|
|
|
this.requestUpdate();
|
|
|
|
// Auto-load models if OpenAI-compatible is selected and URL is set
|
|
if (
|
|
this._responseProvider === "openai-compatible" &&
|
|
this._openaiCompatibleBaseUrl
|
|
) {
|
|
this._loadModels();
|
|
}
|
|
} catch (e) {
|
|
console.error("Error loading MainView storage:", e);
|
|
}
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
document.addEventListener("keydown", this.boundKeydownHandler);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
document.removeEventListener("keydown", this.boundKeydownHandler);
|
|
if (this._animId) cancelAnimationFrame(this._animId);
|
|
if (this._loadModelsTimeout) clearTimeout(this._loadModelsTimeout);
|
|
}
|
|
|
|
updated(changedProperties) {
|
|
super.updated(changedProperties);
|
|
if (changedProperties.has("_mode")) {
|
|
// Stop old animation when switching modes
|
|
if (this._animId) {
|
|
cancelAnimationFrame(this._animId);
|
|
this._animId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
_initButtonAurora() {
|
|
const btn = this.shadowRoot.querySelector(".start-button");
|
|
const aurora = this.shadowRoot.querySelector("canvas.btn-aurora");
|
|
const dither = this.shadowRoot.querySelector("canvas.btn-dither");
|
|
if (!aurora || !dither || !btn) return;
|
|
|
|
// Mouse tracking
|
|
this._mouseX = -1;
|
|
this._mouseY = -1;
|
|
btn.addEventListener("mousemove", (e) => {
|
|
const rect = btn.getBoundingClientRect();
|
|
this._mouseX = (e.clientX - rect.left) / rect.width;
|
|
this._mouseY = (e.clientY - rect.top) / rect.height;
|
|
});
|
|
btn.addEventListener("mouseleave", () => {
|
|
this._mouseX = -1;
|
|
this._mouseY = -1;
|
|
});
|
|
|
|
// Dither
|
|
const blockSize = 8;
|
|
const cols = Math.ceil(aurora.offsetWidth / blockSize);
|
|
const rows = Math.ceil(aurora.offsetHeight / blockSize);
|
|
dither.width = cols;
|
|
dither.height = rows;
|
|
const dCtx = dither.getContext("2d");
|
|
const img = dCtx.createImageData(cols, rows);
|
|
for (let i = 0; i < img.data.length; i += 4) {
|
|
const v = Math.random() > 0.5 ? 255 : 0;
|
|
img.data[i] = v;
|
|
img.data[i + 1] = v;
|
|
img.data[i + 2] = v;
|
|
img.data[i + 3] = 255;
|
|
}
|
|
dCtx.putImageData(img, 0, 0);
|
|
|
|
// Aurora
|
|
const ctx = aurora.getContext("2d");
|
|
const scale = 0.4;
|
|
aurora.width = Math.floor(aurora.offsetWidth * scale);
|
|
aurora.height = Math.floor(aurora.offsetHeight * scale);
|
|
|
|
const blobs = [
|
|
{ color: [120, 160, 230], x: 0.1, y: 0.3, vx: 0.25, vy: 0.2, phase: 0 },
|
|
{
|
|
color: [150, 120, 220],
|
|
x: 0.8,
|
|
y: 0.5,
|
|
vx: -0.2,
|
|
vy: 0.25,
|
|
phase: 1.5,
|
|
},
|
|
{
|
|
color: [200, 140, 210],
|
|
x: 0.5,
|
|
y: 0.6,
|
|
vx: 0.18,
|
|
vy: -0.22,
|
|
phase: 3.0,
|
|
},
|
|
{ color: [100, 190, 190], x: 0.3, y: 0.7, vx: 0.3, vy: 0.15, phase: 4.5 },
|
|
{
|
|
color: [220, 170, 130],
|
|
x: 0.7,
|
|
y: 0.4,
|
|
vx: -0.22,
|
|
vy: -0.25,
|
|
phase: 6.0,
|
|
},
|
|
];
|
|
|
|
const draw = () => {
|
|
this._time += 0.008;
|
|
const w = aurora.width;
|
|
const h = aurora.height;
|
|
const maxDim = Math.max(w, h);
|
|
|
|
ctx.fillStyle = "#f0f0f0";
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
const hovering = this._mouseX >= 0;
|
|
|
|
for (const blob of blobs) {
|
|
const t = this._time;
|
|
const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.4) * w;
|
|
const cy =
|
|
(blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.4) * h;
|
|
const r = maxDim * 0.45;
|
|
|
|
let boost = 1;
|
|
if (hovering) {
|
|
const dx = cx / w - this._mouseX;
|
|
const dy = cy / h - this._mouseY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
boost = 1 + 2.5 * Math.max(0, 1 - dist / 0.6);
|
|
}
|
|
|
|
const a0 = Math.min(1, 0.18 * boost);
|
|
const a1 = Math.min(1, 0.08 * boost);
|
|
const a2 = Math.min(1, 0.02 * boost);
|
|
|
|
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
|
grad.addColorStop(
|
|
0,
|
|
`rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a0})`,
|
|
);
|
|
grad.addColorStop(
|
|
0.3,
|
|
`rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a1})`,
|
|
);
|
|
grad.addColorStop(
|
|
0.6,
|
|
`rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, ${a2})`,
|
|
);
|
|
grad.addColorStop(
|
|
1,
|
|
`rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`,
|
|
);
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, w, h);
|
|
}
|
|
|
|
this._animId = requestAnimationFrame(draw);
|
|
};
|
|
|
|
draw();
|
|
}
|
|
|
|
_handleKeydown(e) {
|
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
if ((isMac ? e.metaKey : e.ctrlKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
this._handleStart();
|
|
}
|
|
}
|
|
|
|
// ── Persistence ──
|
|
|
|
async _saveMode(mode) {
|
|
this._mode = mode;
|
|
this._keyError = false;
|
|
await cheatingDaddy.storage.updatePreference("providerMode", mode);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveGeminiKey(val) {
|
|
this._geminiKey = val;
|
|
this._keyError = false;
|
|
await cheatingDaddy.storage.setApiKey(val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveGroqKey(val) {
|
|
this._groqKey = val;
|
|
await cheatingDaddy.storage.setGroqApiKey(val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveOpenaiKey(val) {
|
|
this._openaiKey = val;
|
|
try {
|
|
const creds = await cheatingDaddy.storage
|
|
.getCredentials()
|
|
.catch(() => ({}));
|
|
await cheatingDaddy.storage.setCredentials({ ...creds, openaiKey: val });
|
|
} catch (e) {}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveOpenAICompatibleApiKey(val) {
|
|
this._openaiCompatibleApiKey = val;
|
|
await cheatingDaddy.storage.setOpenAICompatibleConfig(
|
|
val,
|
|
this._openaiCompatibleBaseUrl,
|
|
this._openaiCompatibleModel,
|
|
);
|
|
this.requestUpdate();
|
|
// Auto-load models when both key and URL are set
|
|
this._debouncedLoadModels();
|
|
}
|
|
|
|
async _saveOpenAICompatibleBaseUrl(val) {
|
|
this._openaiCompatibleBaseUrl = val;
|
|
await cheatingDaddy.storage.setOpenAICompatibleConfig(
|
|
this._openaiCompatibleApiKey,
|
|
val,
|
|
this._openaiCompatibleModel,
|
|
);
|
|
this.requestUpdate();
|
|
// Auto-load models when both key and URL are set
|
|
this._debouncedLoadModels();
|
|
}
|
|
|
|
async _saveOpenAICompatibleModel(val) {
|
|
this._openaiCompatibleModel = val;
|
|
await cheatingDaddy.storage.setOpenAICompatibleConfig(
|
|
this._openaiCompatibleApiKey,
|
|
this._openaiCompatibleBaseUrl,
|
|
val,
|
|
);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveResponseProvider(val) {
|
|
this._responseProvider = val;
|
|
await cheatingDaddy.storage.updatePreference("responseProvider", val);
|
|
this.requestUpdate();
|
|
|
|
// Auto-load models when switching to openai-compatible
|
|
if (val === "openai-compatible" && this._openaiCompatibleBaseUrl) {
|
|
this._loadModels();
|
|
}
|
|
}
|
|
|
|
async _loadModels() {
|
|
if (
|
|
this._responseProvider !== "openai-compatible" ||
|
|
!this._openaiCompatibleBaseUrl
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this._loadingModels = true;
|
|
this._availableModels = [];
|
|
this.requestUpdate();
|
|
|
|
try {
|
|
let modelsUrl = this._openaiCompatibleBaseUrl.trim();
|
|
modelsUrl = modelsUrl.replace(/\/$/, "");
|
|
if (!modelsUrl.includes("/models")) {
|
|
modelsUrl = modelsUrl.includes("/v1")
|
|
? modelsUrl + "/models"
|
|
: modelsUrl + "/v1/models";
|
|
}
|
|
|
|
console.log("Loading models from:", modelsUrl);
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (this._openaiCompatibleApiKey) {
|
|
headers["Authorization"] = `Bearer ${this._openaiCompatibleApiKey}`;
|
|
}
|
|
|
|
const response = await fetch(modelsUrl, { headers });
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.data && Array.isArray(data.data)) {
|
|
this._availableModels = data.data
|
|
.map((m) => m.id || m.model || m.name)
|
|
.filter(Boolean);
|
|
} else if (Array.isArray(data)) {
|
|
this._availableModels = data
|
|
.map((m) => m.id || m.model || m.name || m)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
console.log("Loaded models:", this._availableModels.length);
|
|
|
|
if (
|
|
this._availableModels.length > 0 &&
|
|
!this._availableModels.includes(this._openaiCompatibleModel)
|
|
) {
|
|
await this._saveOpenAICompatibleModel(this._availableModels[0]);
|
|
}
|
|
} catch (error) {
|
|
console.log("Could not load models:", error.message);
|
|
this._availableModels = [];
|
|
} finally {
|
|
this._loadingModels = false;
|
|
this.requestUpdate();
|
|
}
|
|
}
|
|
|
|
_debouncedLoadModels() {
|
|
if (this._loadModelsTimeout) {
|
|
clearTimeout(this._loadModelsTimeout);
|
|
}
|
|
this._loadModelsTimeout = setTimeout(() => {
|
|
this._loadModels();
|
|
}, 500);
|
|
}
|
|
|
|
_toggleManualInput() {
|
|
this._manualModelInput = !this._manualModelInput;
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveOllamaHost(val) {
|
|
this._ollamaHost = val;
|
|
await cheatingDaddy.storage.updatePreference("ollamaHost", val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveOllamaModel(val) {
|
|
this._ollamaModel = val;
|
|
await cheatingDaddy.storage.updatePreference("ollamaModel", val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveWhisperModel(val) {
|
|
this._whisperModel = val;
|
|
if (val === "__custom__") {
|
|
// Don't save yet — wait for the custom input
|
|
this.requestUpdate();
|
|
return;
|
|
}
|
|
this._customWhisperModel = "";
|
|
await cheatingDaddy.storage.updatePreference("whisperModel", val);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
async _saveCustomWhisperModel(val) {
|
|
this._customWhisperModel = val.trim();
|
|
if (this._customWhisperModel) {
|
|
await cheatingDaddy.storage.updatePreference(
|
|
"whisperModel",
|
|
this._customWhisperModel,
|
|
);
|
|
}
|
|
this.requestUpdate();
|
|
}
|
|
|
|
_formatBytes(bytes) {
|
|
if (!bytes || bytes === 0) return "0 B";
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return (bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0) + " " + units[i];
|
|
}
|
|
|
|
_renderWhisperProgress() {
|
|
const p = this.whisperProgress;
|
|
if (!p) return "";
|
|
|
|
const pct = Math.round(p.progress || 0);
|
|
const fileName = p.file ? p.file.split("/").pop() : "";
|
|
|
|
return html`
|
|
<div class="whisper-progress-container">
|
|
<div class="whisper-progress-header">
|
|
<span class="whisper-progress-file" title=${p.file || ""}
|
|
>${fileName || "Preparing..."}</span
|
|
>
|
|
<span class="whisper-progress-pct">${pct}%</span>
|
|
</div>
|
|
<div class="whisper-progress-track">
|
|
<div class="whisper-progress-bar" style="width: ${pct}%"></div>
|
|
</div>
|
|
${p.total
|
|
? html`<div class="whisper-progress-size">
|
|
${this._formatBytes(p.loaded)} / ${this._formatBytes(p.total)}
|
|
</div>`
|
|
: ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_handleProfileChange(e) {
|
|
this.onProfileChange(e.target.value);
|
|
}
|
|
|
|
// ── Start ──
|
|
|
|
_handleStart() {
|
|
if (this.isInitializing) return;
|
|
|
|
if (this._mode === "byok") {
|
|
if (!this._geminiKey.trim()) {
|
|
this._keyError = true;
|
|
this.requestUpdate();
|
|
return;
|
|
}
|
|
} else if (this._mode === "local") {
|
|
// Local mode doesn't need API keys, just Ollama host
|
|
if (!this._ollamaHost.trim()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.onStart();
|
|
}
|
|
|
|
triggerApiKeyError() {
|
|
this._keyError = true;
|
|
this.requestUpdate();
|
|
setTimeout(() => {
|
|
this._keyError = false;
|
|
this.requestUpdate();
|
|
}, 2000);
|
|
}
|
|
|
|
// ── Render helpers ──
|
|
|
|
_renderStartButton() {
|
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
|
|
const cmdIcon = html`<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z"
|
|
/>
|
|
</svg>`;
|
|
const ctrlIcon = html`<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M6 15l6-6 6 6" />
|
|
</svg>`;
|
|
const enterIcon = html`<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M9 10l-5 5 5 5" />
|
|
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
|
</svg>`;
|
|
|
|
return html`
|
|
<button
|
|
class="start-button ${this.isInitializing ? "disabled" : ""}"
|
|
@click=${() => this._handleStart()}
|
|
>
|
|
<canvas class="btn-aurora"></canvas>
|
|
<canvas class="btn-dither"></canvas>
|
|
<span class="btn-label">
|
|
Start Session
|
|
<span class="shortcut-hint"
|
|
>${isMac ? cmdIcon : ctrlIcon}${enterIcon}</span
|
|
>
|
|
</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// ── BYOK mode ──
|
|
|
|
_renderByokMode() {
|
|
return html`
|
|
<div class="form-group">
|
|
<label class="form-label">Gemini API Key</label>
|
|
<input
|
|
type="password"
|
|
placeholder="Required for transcription"
|
|
.value=${this._geminiKey}
|
|
@input=${(e) => this._saveGeminiKey(e.target.value)}
|
|
class=${this._keyError ? "error" : ""}
|
|
/>
|
|
<div class="form-hint">
|
|
<span
|
|
class="link"
|
|
@click=${() =>
|
|
this.onExternalLink("https://aistudio.google.com/apikey")}
|
|
>Get Gemini key</span
|
|
>
|
|
- Always used for audio transcription
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Response Provider</label>
|
|
<select
|
|
.value=${this._responseProvider}
|
|
@change=${(e) => this._saveResponseProvider(e.target.value)}
|
|
>
|
|
<option
|
|
value="gemini"
|
|
?selected=${this._responseProvider === "gemini"}
|
|
>
|
|
Gemini (default)
|
|
</option>
|
|
<option value="groq" ?selected=${this._responseProvider === "groq"}>
|
|
Groq (fast responses)
|
|
</option>
|
|
<option
|
|
value="openai-compatible"
|
|
?selected=${this._responseProvider === "openai-compatible"}
|
|
>
|
|
OpenAI-Compatible API
|
|
</option>
|
|
</select>
|
|
<div class="form-hint">
|
|
Choose which API to use for generating responses
|
|
</div>
|
|
</div>
|
|
|
|
${this._responseProvider === "groq"
|
|
? html`
|
|
<div class="form-group">
|
|
<label class="form-label">Groq API Key</label>
|
|
<input
|
|
type="password"
|
|
placeholder="Required for Groq"
|
|
.value=${this._groqKey}
|
|
@input=${(e) => this._saveGroqKey(e.target.value)}
|
|
/>
|
|
<div class="form-hint">
|
|
<span
|
|
class="link"
|
|
@click=${() =>
|
|
this.onExternalLink("https://console.groq.com/keys")}
|
|
>Get Groq key</span
|
|
>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""}
|
|
${this._responseProvider === "openai-compatible"
|
|
? html`
|
|
<div class="form-group">
|
|
<label class="form-label">OpenAI-Compatible API</label>
|
|
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
<input
|
|
type="password"
|
|
placeholder="API Key"
|
|
.value=${this._openaiCompatibleApiKey}
|
|
@input=${(e) =>
|
|
this._saveOpenAICompatibleApiKey(e.target.value)}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Base URL (e.g., https://openrouter.ai/api)"
|
|
.value=${this._openaiCompatibleBaseUrl}
|
|
@input=${(e) =>
|
|
this._saveOpenAICompatibleBaseUrl(e.target.value)}
|
|
/>
|
|
${this._loadingModels
|
|
? html`
|
|
<input
|
|
type="text"
|
|
placeholder="Loading models..."
|
|
disabled
|
|
style="opacity: 0.6;"
|
|
/>
|
|
`
|
|
: this._availableModels.length > 0 && !this._manualModelInput
|
|
? html`
|
|
<div style="display: flex; gap: 4px;">
|
|
<select
|
|
style="flex: 1;"
|
|
.value=${this._openaiCompatibleModel}
|
|
@change=${(e) =>
|
|
this._saveOpenAICompatibleModel(e.target.value)}
|
|
>
|
|
${this._availableModels.map(
|
|
(model) => html`
|
|
<option
|
|
value="${model}"
|
|
?selected=${this._openaiCompatibleModel ===
|
|
model}
|
|
>
|
|
${model}
|
|
</option>
|
|
`,
|
|
)}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
@click=${() => this._toggleManualInput()}
|
|
style="padding: 8px 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-muted); font-size: var(--font-size-xs);"
|
|
title="Enter model manually"
|
|
>
|
|
✏️
|
|
</button>
|
|
</div>
|
|
`
|
|
: html`
|
|
<div style="display: flex; gap: 4px;">
|
|
<input
|
|
type="text"
|
|
placeholder="Model name (e.g., anthropic/claude-3.5-sonnet)"
|
|
style="flex: 1;"
|
|
.value=${this._openaiCompatibleModel}
|
|
@input=${(e) =>
|
|
this._saveOpenAICompatibleModel(e.target.value)}
|
|
/>
|
|
${this._availableModels.length > 0
|
|
? html`
|
|
<button
|
|
type="button"
|
|
@click=${() => this._toggleManualInput()}
|
|
style="padding: 8px 12px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; color: var(--text-muted); font-size: var(--font-size-xs);"
|
|
title="Select from list"
|
|
>
|
|
📋
|
|
</button>
|
|
`
|
|
: ""}
|
|
</div>
|
|
`}
|
|
</div>
|
|
<div class="form-hint">
|
|
${this._loadingModels
|
|
? "Loading available models..."
|
|
: this._availableModels.length > 0
|
|
? `${this._availableModels.length} models available`
|
|
: "Use OpenRouter, DeepSeek, Together AI, or any OpenAI-compatible API"}
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""}
|
|
${this._renderStartButton()}
|
|
`;
|
|
}
|
|
|
|
// ── Local AI mode ──
|
|
|
|
_renderLocalMode() {
|
|
return html`
|
|
<div class="form-group">
|
|
<label class="form-label">Ollama Host</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)}
|
|
/>
|
|
<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
|
|
</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>`
|
|
: ""}
|
|
</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>
|
|
|
|
${this._renderStartButton()}
|
|
`;
|
|
}
|
|
|
|
// ── Main render ──
|
|
|
|
render() {
|
|
const helpIcon = html`<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<g
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0m9 5v.01" />
|
|
<path d="M12 13.5a1.5 1.5 0 0 1 1-1.5a2.6 2.6 0 1 0-3-4" />
|
|
</g>
|
|
</svg>`;
|
|
const closeIcon = html`<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M18 6L6 18M6 6l12 12"
|
|
/>
|
|
</svg>`;
|
|
|
|
return html`
|
|
<div class="form-wrapper">
|
|
${this._mode === "local"
|
|
? html`
|
|
<div class="title-row">
|
|
<div class="page-title">
|
|
Mastermind <span class="mode-suffix">Local AI</span>
|
|
</div>
|
|
<button
|
|
class="help-btn"
|
|
@click=${() => {
|
|
this._showLocalHelp = !this._showLocalHelp;
|
|
}}
|
|
>
|
|
${this._showLocalHelp ? closeIcon : helpIcon}
|
|
</button>
|
|
</div>
|
|
`
|
|
: html`
|
|
<div class="page-title">
|
|
Mastermind <span class="mode-suffix">BYOK</span>
|
|
</div>
|
|
`}
|
|
<div class="page-subtitle">
|
|
${this._mode === "byok"
|
|
? "Bring your own API keys"
|
|
: "Run models locally on your machine"}
|
|
</div>
|
|
|
|
${this._mode === "byok" ? this._renderByokMode() : ""}
|
|
${this._mode === "local"
|
|
? this._showLocalHelp
|
|
? this._renderLocalHelp()
|
|
: this._renderLocalMode()
|
|
: ""}
|
|
|
|
<div class="divider">
|
|
<div class="divider-line"></div>
|
|
<div class="divider-text">or switch to</div>
|
|
<div class="divider-line"></div>
|
|
</div>
|
|
|
|
<div class="mode-links">
|
|
<button
|
|
class="mode-link"
|
|
@click=${() =>
|
|
this._saveMode(this._mode === "byok" ? "local" : "byok")}
|
|
>
|
|
${this._mode === "byok" ? "Local AI Mode" : "BYOK Mode (API Keys)"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderLocalHelp() {
|
|
return html`
|
|
<div class="help-content">
|
|
<div class="help-section">
|
|
<div class="help-section-title">What is Ollama?</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.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">Install Ollama</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.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">Ollama must be running</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:
|
|
</div>
|
|
<code class="help-code">ollama serve</code>
|
|
</div>
|
|
|
|
<div class="help-section">
|
|
<div class="help-section-title">Pull a model</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.
|
|
</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.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define("main-view", MainView);
|