Files
Mastermind/src/components/views/AssistantView.js
T
Илья Глазунов 06e178762d
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
Rename project from "Cheating Daddy" to "Mastermind" across all files, update version to 0.6.0, and implement migration functionality for users with existing configurations. Enhance onboarding experience with migration options and update relevant documentation.
2026-01-16 01:16:43 +03:00

831 lines
28 KiB
JavaScript

import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class AssistantView extends LitElement {
static styles = css`
:host {
height: 100%;
display: flex;
flex-direction: column;
}
* {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
cursor: default;
}
.response-container {
height: calc(100% - 50px);
overflow-y: auto;
font-size: var(--response-font-size, 16px);
line-height: 1.6;
background: var(--bg-primary);
padding: 12px;
scroll-behavior: smooth;
user-select: text;
cursor: text;
}
.response-container * {
user-select: text;
cursor: text;
}
.response-container a {
cursor: pointer;
}
/* Word display (no animation) */
.response-container [data-word] {
display: inline-block;
}
/* Markdown styling */
.response-container h1,
.response-container h2,
.response-container h3,
.response-container h4,
.response-container h5,
.response-container h6 {
margin: 1em 0 0.5em 0;
color: var(--text-color);
font-weight: 600;
}
.response-container h1 {
font-size: 1.6em;
}
.response-container h2 {
font-size: 1.4em;
}
.response-container h3 {
font-size: 1.2em;
}
.response-container h4 {
font-size: 1.1em;
}
.response-container h5 {
font-size: 1em;
}
.response-container h6 {
font-size: 0.9em;
}
.response-container p {
margin: 0.6em 0;
color: var(--text-color);
}
.response-container ul,
.response-container ol {
margin: 0.6em 0;
padding-left: 1.5em;
color: var(--text-color);
}
.response-container li {
margin: 0.3em 0;
}
.response-container blockquote {
margin: 0.8em 0;
padding: 0.5em 1em;
border-left: 2px solid var(--border-default);
background: var(--bg-secondary);
}
.response-container code {
background: var(--bg-tertiary);
padding: 0.15em 0.4em;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.85em;
}
.response-container pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 12px;
overflow-x: auto;
margin: 0.8em 0;
}
.response-container pre code {
background: none;
padding: 0;
}
.response-container a {
color: var(--text-color);
text-decoration: underline;
text-underline-offset: 2px;
}
.response-container strong,
.response-container b {
font-weight: 600;
}
.response-container hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1.5em 0;
}
.response-container table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
}
.response-container th,
.response-container td {
border: 1px solid var(--border-color);
padding: 8px;
text-align: left;
}
.response-container th {
background: var(--bg-secondary);
font-weight: 600;
}
.response-container::-webkit-scrollbar {
width: 8px;
}
.response-container::-webkit-scrollbar-track {
background: transparent;
}
.response-container::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
.response-container::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
.text-input-container {
display: flex;
gap: 8px;
margin-top: 8px;
align-items: center;
}
.text-input-container input {
flex: 1;
background: transparent;
color: var(--text-color);
border: none;
border-bottom: 1px solid var(--border-color);
padding: 8px 4px;
border-radius: 0;
font-size: 13px;
}
.text-input-container input:focus {
outline: none;
border-bottom-color: var(--text-color);
}
.text-input-container input::placeholder {
color: var(--placeholder-color);
}
.nav-button {
background: transparent;
color: var(--text-secondary);
border: none;
padding: 6px;
border-radius: 3px;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
}
.nav-button:hover {
background: var(--hover-background);
color: var(--text-color);
}
.nav-button:disabled {
opacity: 0.3;
}
.nav-button svg {
width: 18px;
height: 18px;
stroke: currentColor;
}
.response-counter {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
min-width: 50px;
text-align: center;
font-family: 'SF Mono', Monaco, monospace;
}
.screen-answer-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--btn-primary-bg, #ffffff);
color: var(--btn-primary-text, #000000);
border: none;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.screen-answer-btn:hover {
background: var(--btn-primary-hover, #f0f0f0);
}
.screen-answer-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.screen-answer-btn .usage-count {
font-size: 11px;
opacity: 0.7;
font-family: 'SF Mono', Monaco, monospace;
}
.screen-answer-btn-wrapper {
position: relative;
}
.screen-answer-btn-wrapper .tooltip {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--tooltip-bg, #1a1a1a);
color: var(--tooltip-text, #ffffff);
padding: 8px 12px;
border-radius: 6px;
font-size: 11px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition:
opacity 0.15s ease,
visibility 0.15s ease;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
}
.screen-answer-btn-wrapper .tooltip::after {
content: '';
position: absolute;
top: 100%;
right: 16px;
border: 6px solid transparent;
border-top-color: var(--tooltip-bg, #1a1a1a);
}
.screen-answer-btn-wrapper:hover .tooltip {
opacity: 1;
visibility: visible;
}
.tooltip-row {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 4px;
}
.tooltip-row:last-child {
margin-bottom: 0;
}
.tooltip-label {
opacity: 0.7;
}
.tooltip-value {
font-family: 'SF Mono', Monaco, monospace;
}
.tooltip-note {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
opacity: 0.5;
font-size: 10px;
}
.capture-buttons {
display: flex;
gap: 6px;
}
.region-select-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 6px 10px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.region-select-btn:hover {
background: var(--hover-background);
color: var(--text-color);
border-color: var(--text-color);
}
.region-select-btn svg {
width: 16px;
height: 16px;
}
.region-select-btn span {
margin-left: 4px;
}
.ptt-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.ptt-toggle-btn:hover {
background: var(--hover-background);
color: var(--text-color);
border-color: var(--text-color);
}
.ptt-toggle-btn.active {
color: var(--error-color);
border-color: var(--error-color);
}
.ptt-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.ptt-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color);
box-shadow: 0 0 0 1px var(--border-color);
}
.ptt-dot.active {
background: var(--error-color);
box-shadow: 0 0 0 1px var(--error-color);
}
.ptt-label {
font-family: 'SF Mono', Monaco, monospace;
}
`;
static properties = {
responses: { type: Array },
currentResponseIndex: { type: Number },
selectedProfile: { type: String },
onSendText: { type: Function },
shouldAnimateResponse: { type: Boolean },
flashCount: { type: Number },
flashLiteCount: { type: Number },
aiProvider: { type: String },
pushToTalkActive: { type: Boolean },
audioInputMode: { type: String },
pushToTalkKeybind: { type: String },
};
constructor() {
super();
this.responses = [];
this.currentResponseIndex = -1;
this.selectedProfile = 'interview';
this.onSendText = () => {};
this.flashCount = 0;
this.flashLiteCount = 0;
this.aiProvider = 'gemini';
this.pushToTalkActive = false;
this.audioInputMode = 'auto';
this.pushToTalkKeybind = '';
}
getProfileNames() {
return {
interview: 'Job Interview',
sales: 'Sales Call',
meeting: 'Business Meeting',
presentation: 'Presentation',
negotiation: 'Negotiation',
exam: 'Exam Assistant',
};
}
getCurrentResponse() {
const profileNames = this.getProfileNames();
return this.responses.length > 0 && this.currentResponseIndex >= 0
? this.responses[this.currentResponseIndex]
: `Hey, Im listening to your ${profileNames[this.selectedProfile] || 'session'}?`;
}
renderMarkdown(content) {
// Check if marked is available
if (typeof window !== 'undefined' && window.marked) {
try {
// Configure marked for better security and formatting
window.marked.setOptions({
breaks: true,
gfm: true,
sanitize: false, // We trust the AI responses
});
let rendered = window.marked.parse(content);
rendered = this.wrapWordsInSpans(rendered);
return rendered;
} catch (error) {
console.warn('Error parsing markdown:', error);
return content; // Fallback to plain text
}
}
console.log('Marked not available, using plain text');
return content; // Fallback if marked is not available
}
wrapWordsInSpans(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const tagsToSkip = ['PRE'];
function wrap(node) {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() && !tagsToSkip.includes(node.parentNode.tagName)) {
const words = node.textContent.split(/(\s+)/);
const frag = document.createDocumentFragment();
words.forEach(word => {
if (word.trim()) {
const span = document.createElement('span');
span.setAttribute('data-word', '');
span.textContent = word;
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(word));
}
});
node.parentNode.replaceChild(frag, node);
} else if (node.nodeType === Node.ELEMENT_NODE && !tagsToSkip.includes(node.tagName)) {
Array.from(node.childNodes).forEach(wrap);
}
}
Array.from(doc.body.childNodes).forEach(wrap);
return doc.body.innerHTML;
}
getResponseCounter() {
return this.responses.length > 0 ? `${this.currentResponseIndex + 1}/${this.responses.length}` : '';
}
navigateToPreviousResponse() {
if (this.currentResponseIndex > 0) {
this.currentResponseIndex--;
this.dispatchEvent(
new CustomEvent('response-index-changed', {
detail: { index: this.currentResponseIndex },
})
);
this.requestUpdate();
}
}
navigateToNextResponse() {
if (this.currentResponseIndex < this.responses.length - 1) {
this.currentResponseIndex++;
this.dispatchEvent(
new CustomEvent('response-index-changed', {
detail: { index: this.currentResponseIndex },
})
);
this.requestUpdate();
}
}
scrollResponseUp() {
const container = this.shadowRoot.querySelector('.response-container');
if (container) {
const scrollAmount = container.clientHeight * 0.3; // Scroll 30% of container height
container.scrollTop = Math.max(0, container.scrollTop - scrollAmount);
}
}
scrollResponseDown() {
const container = this.shadowRoot.querySelector('.response-container');
if (container) {
const scrollAmount = container.clientHeight * 0.3; // Scroll 30% of container height
container.scrollTop = Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + scrollAmount);
}
}
connectedCallback() {
super.connectedCallback();
// Load limits on mount
this.loadLimits();
this.loadPushToTalkKeybind();
// Set up IPC listeners for keyboard shortcuts
if (window.require) {
const { ipcRenderer } = window.require('electron');
this.handlePreviousResponse = () => {
console.log('Received navigate-previous-response message');
this.navigateToPreviousResponse();
};
this.handleNextResponse = () => {
console.log('Received navigate-next-response message');
this.navigateToNextResponse();
};
this.handleScrollUp = () => {
console.log('Received scroll-response-up message');
this.scrollResponseUp();
};
this.handleScrollDown = () => {
console.log('Received scroll-response-down message');
this.scrollResponseDown();
};
this.handlePushToTalkState = (event, state) => {
this.pushToTalkActive = state?.active ?? false;
this.audioInputMode = state?.inputMode ?? 'auto';
this.requestUpdate();
};
ipcRenderer.on('navigate-previous-response', this.handlePreviousResponse);
ipcRenderer.on('navigate-next-response', this.handleNextResponse);
ipcRenderer.on('scroll-response-up', this.handleScrollUp);
ipcRenderer.on('scroll-response-down', this.handleScrollDown);
ipcRenderer.on('push-to-talk-state', this.handlePushToTalkState);
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up IPC listeners
if (window.require) {
const { ipcRenderer } = window.require('electron');
if (this.handlePreviousResponse) {
ipcRenderer.removeListener('navigate-previous-response', this.handlePreviousResponse);
}
if (this.handleNextResponse) {
ipcRenderer.removeListener('navigate-next-response', this.handleNextResponse);
}
if (this.handleScrollUp) {
ipcRenderer.removeListener('scroll-response-up', this.handleScrollUp);
}
if (this.handleScrollDown) {
ipcRenderer.removeListener('scroll-response-down', this.handleScrollDown);
}
if (this.handlePushToTalkState) {
ipcRenderer.removeListener('push-to-talk-state', this.handlePushToTalkState);
}
}
}
async handleSendText() {
const textInput = this.shadowRoot.querySelector('#textInput');
if (textInput && textInput.value.trim()) {
const message = textInput.value.trim();
textInput.value = ''; // Clear input
await this.onSendText(message);
}
}
handleTextKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.handleSendText();
}
}
async loadLimits() {
if (window.mastermind?.storage?.getTodayLimits) {
const limits = await window.mastermind.storage.getTodayLimits();
this.flashCount = limits.flash?.count || 0;
this.flashLiteCount = limits.flashLite?.count || 0;
}
}
async loadPushToTalkKeybind() {
if (window.mastermind?.storage?.getKeybinds) {
const isMac = window.mastermind?.isMacOS || navigator.platform.includes('Mac');
const defaultKeybind = isMac ? 'Ctrl+Space' : 'Ctrl+Space';
const keybinds = await window.mastermind.storage.getKeybinds();
this.pushToTalkKeybind = keybinds?.pushToTalk || defaultKeybind;
}
}
getTotalUsed() {
return this.flashCount + this.flashLiteCount;
}
getTotalAvailable() {
return 40; // 20 flash + 20 flash-lite
}
async handleScreenAnswer() {
if (window.captureManualScreenshot) {
window.captureManualScreenshot();
// Reload limits after a short delay to catch the update
setTimeout(() => this.loadLimits(), 1000);
}
}
handleRegionSelect() {
if (window.startRegionSelection) {
window.startRegionSelection();
// Reload limits after a short delay to catch the update
setTimeout(() => this.loadLimits(), 1000);
}
}
handlePushToTalkToggle() {
if (!window.require) {
return;
}
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('push-to-talk-toggle');
}
scrollToBottom() {
setTimeout(() => {
const container = this.shadowRoot.querySelector('.response-container');
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 0);
}
firstUpdated() {
super.firstUpdated();
this.updateResponseContent();
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('responses') || changedProperties.has('currentResponseIndex')) {
this.updateResponseContent();
}
}
updateResponseContent() {
console.log('updateResponseContent called');
const container = this.shadowRoot.querySelector('#responseContainer');
if (container) {
const currentResponse = this.getCurrentResponse();
console.log('Current response:', currentResponse);
const renderedResponse = this.renderMarkdown(currentResponse);
console.log('Rendered response:', renderedResponse);
container.innerHTML = renderedResponse;
// Show all words immediately (no animation)
if (this.shouldAnimateResponse) {
this.dispatchEvent(new CustomEvent('response-animation-complete', { bubbles: true, composed: true }));
}
} else {
console.log('Response container not found');
}
}
render() {
const responseCounter = this.getResponseCounter();
const showPushToTalk = this.aiProvider === 'openai-sdk' && this.audioInputMode === 'push-to-talk';
const keybindLabel = this.pushToTalkKeybind || 'Hotkey';
const pushToTalkLabel = this.pushToTalkActive
? 'Recording...'
: `Press ${keybindLabel} to start/stop`;
const pushToTalkButtonLabel = this.pushToTalkActive ? 'Stop' : 'Record';
return html`
<div class="response-container" id="responseContainer"></div>
${showPushToTalk
? html`
<div class="ptt-indicator">
<span class="ptt-dot ${this.pushToTalkActive ? 'active' : ''}"></span>
<span>Push-to-Talk:</span>
<span class="ptt-label">${pushToTalkLabel}</span>
</div>
`
: ''}
<div class="text-input-container">
<button class="nav-button" @click=${this.navigateToPreviousResponse} ?disabled=${this.currentResponseIndex <= 0}>
<svg width="24px" height="24px" stroke-width="1.7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
${this.responses.length > 0 ? html`<span class="response-counter">${responseCounter}</span>` : ''}
<button class="nav-button" @click=${this.navigateToNextResponse} ?disabled=${this.currentResponseIndex >= this.responses.length - 1}>
<svg width="24px" height="24px" stroke-width="1.7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<input type="text" id="textInput" placeholder="Type a message to the AI..." @keydown=${this.handleTextKeydown} />
<div class="capture-buttons">
${showPushToTalk
? html`
<button
class="ptt-toggle-btn ${this.pushToTalkActive ? 'active' : ''}"
@click=${this.handlePushToTalkToggle}
title="Toggle Push-to-Talk recording"
>
${pushToTalkButtonLabel}
</button>
`
: ''}
<button class="region-select-btn" @click=${this.handleRegionSelect} title="Select region to analyze (like Win+Shift+S)">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.25 2A2.25 2.25 0 0 0 2 4.25v2.5A.75.75 0 0 0 3.5 6.75v-2.5a.75.75 0 0 1 .75-.75h2.5A.75.75 0 0 0 6.75 2h-2.5Zm9.5 0a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A2.25 2.25 0 0 0 16.25 2h-2.5ZM3.5 13.25a.75.75 0 0 0-1.5 0v2.5A2.25 2.25 0 0 0 4.25 18h2.5a.75.75 0 0 0 0-1.5h-2.5a.75.75 0 0 1-.75-.75v-2.5Zm13.5 0a.75.75 0 0 0 1.5 0v2.5A2.25 2.25 0 0 1 16.25 18h-2.5a.75.75 0 0 1 0-1.5h2.5a.75.75 0 0 0 .75-.75v-2.5Z"
clip-rule="evenodd"
/>
</svg>
<span>Select region</span>
</button>
<div class="screen-answer-btn-wrapper">
${this.aiProvider === 'gemini'
? html`
<div class="tooltip">
<div class="tooltip-row">
<span class="tooltip-label">Flash</span>
<span class="tooltip-value">${this.flashCount}/20</span>
</div>
<div class="tooltip-row">
<span class="tooltip-label">Flash Lite</span>
<span class="tooltip-value">${this.flashLiteCount}/20</span>
</div>
<div class="tooltip-note">Resets every 24 hours</div>
</div>
`
: ''}
<button class="screen-answer-btn" @click=${this.handleScreenAnswer}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
d="M15.98 1.804a1 1 0 0 0-1.96 0l-.24 1.192a1 1 0 0 1-.784.785l-1.192.238a1 1 0 0 0 0 1.962l1.192.238a1 1 0 0 1 .785.785l.238 1.192a1 1 0 0 0 1.962 0l.238-1.192a1 1 0 0 1 .785-.785l1.192-.238a1 1 0 0 0 0-1.962l-1.192-.238a1 1 0 0 1-.785-.785l-.238-1.192ZM6.949 5.684a1 1 0 0 0-1.898 0l-.683 2.051a1 1 0 0 1-.633.633l-2.051.683a1 1 0 0 0 0 1.898l2.051.684a1 1 0 0 1 .633.632l.683 2.051a1 1 0 0 0 1.898 0l.683-2.051a1 1 0 0 1 .633-.633l2.051-.683a1 1 0 0 0 0-1.898l-2.051-.683a1 1 0 0 1-.633-.633L6.95 5.684ZM13.949 13.684a1 1 0 0 0-1.898 0l-.184.551a1 1 0 0 1-.632.633l-.551.183a1 1 0 0 0 0 1.898l.551.183a1 1 0 0 1 .633.633l.183.551a1 1 0 0 0 1.898 0l.184-.551a1 1 0 0 1 .632-.633l.551-.183a1 1 0 0 0 0-1.898l-.551-.184a1 1 0 0 1-.633-.632l-.183-.551Z"
/>
</svg>
<span>Full screen</span>
${this.aiProvider === 'gemini'
? html`<span class="usage-count">(${this.getTotalUsed()}/${this.getTotalAvailable()})</span>`
: ''}
</button>
</div>
</div>
</div>
`;
}
}
customElements.define('assistant-view', AssistantView);