Files
Mastermind/src/components/views/AssistantView.js
T

713 lines
23 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: var(--font);
cursor: default;
}
/* ── Response area ── */
.response-container {
flex: 1;
overflow-y: auto;
font-size: var(--response-font-size, 15px);
line-height: var(--line-height);
background: var(--bg-app);
padding: var(--space-sm) var(--space-md);
scroll-behavior: smooth;
user-select: text;
cursor: text;
color: var(--text-primary);
}
.response-container * {
user-select: text;
cursor: text;
}
.response-container a {
cursor: pointer;
}
.response-container [data-word] {
display: inline-block;
}
/* ── Markdown ── */
.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-primary);
font-weight: var(--font-weight-semibold);
}
.response-container h1 { font-size: 1.5em; }
.response-container h2 { font-size: 1.3em; }
.response-container h3 { font-size: 1.15em; }
.response-container h4 { font-size: 1.05em; }
.response-container h5,
.response-container h6 { font-size: 1em; }
.response-container p {
margin: 0.6em 0;
color: var(--text-primary);
}
.response-container ul,
.response-container ol {
margin: 0.6em 0;
padding-left: 1.5em;
color: var(--text-primary);
}
.response-container li {
margin: 0.3em 0;
}
.response-container blockquote {
margin: 0.8em 0;
padding: 0.5em 1em;
border-left: 2px solid var(--border-strong);
background: var(--bg-surface);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.response-container code {
background: var(--bg-elevated);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.85em;
}
.response-container pre {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md);
overflow-x: auto;
margin: 0.8em 0;
}
.response-container pre code {
background: none;
padding: 0;
}
.response-container a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.response-container strong,
.response-container b {
font-weight: var(--font-weight-semibold);
}
.response-container hr {
border: none;
border-top: 1px solid var(--border);
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);
padding: var(--space-sm);
text-align: left;
}
.response-container th {
background: var(--bg-surface);
font-weight: var(--font-weight-semibold);
}
.response-container::-webkit-scrollbar {
width: 6px;
}
.response-container::-webkit-scrollbar-track {
background: transparent;
}
.response-container::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
.response-container::-webkit-scrollbar-thumb:hover {
background: #444444;
}
/* ── Response navigation strip ── */
.response-nav {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
border-top: 1px solid var(--border);
background: var(--bg-app);
}
.nav-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition);
}
.nav-btn:hover:not(:disabled) {
color: var(--text-primary);
}
.nav-btn:disabled {
opacity: 0.25;
cursor: default;
}
.nav-btn svg {
width: 14px;
height: 14px;
}
.response-counter {
font-size: var(--font-size-xs);
color: var(--text-muted);
font-family: var(--font-mono);
min-width: 40px;
text-align: center;
}
/* ── Bottom input bar ── */
.input-bar {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
background: var(--bg-app);
}
.input-bar-inner {
display: flex;
align-items: center;
flex: 1;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 100px;
padding: 0 var(--space-md);
height: 32px;
transition: border-color var(--transition);
}
.input-bar-inner:focus-within {
border-color: var(--accent);
}
.input-bar-inner input {
flex: 1;
background: none;
color: var(--text-primary);
border: none;
padding: 0;
font-size: var(--font-size-sm);
font-family: var(--font);
height: 100%;
outline: none;
}
.input-bar-inner input::placeholder {
color: var(--text-muted);
}
.analyze-btn {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-primary);
cursor: pointer;
font-size: var(--font-size-xs);
font-family: var(--font-mono);
white-space: nowrap;
padding: var(--space-xs) var(--space-md);
border-radius: 100px;
height: 32px;
display: flex;
align-items: center;
gap: 4px;
transition: border-color 0.4s ease, background var(--transition);
flex-shrink: 0;
overflow: hidden;
}
.analyze-btn:hover:not(.analyzing) {
border-color: var(--accent);
background: var(--bg-surface);
}
.analyze-btn.analyzing {
cursor: default;
border-color: transparent;
}
.analyze-btn-content {
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.4s ease;
z-index: 1;
position: relative;
}
.analyze-btn.analyzing .analyze-btn-content {
opacity: 0;
}
.analyze-canvas {
position: absolute;
inset: -1px;
width: calc(100% + 2px);
height: calc(100% + 2px);
pointer-events: none;
}
`;
static properties = {
responses: { type: Array },
currentResponseIndex: { type: Number },
selectedProfile: { type: String },
onSendText: { type: Function },
shouldAnimateResponse: { type: Boolean },
isAnalyzing: { type: Boolean, state: true },
};
constructor() {
super();
this.responses = [];
this.currentResponseIndex = -1;
this.selectedProfile = 'interview';
this.onSendText = () => {};
this.isAnalyzing = false;
this._animFrame = null;
}
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]
: `Listening to your ${profileNames[this.selectedProfile] || 'session'}...`;
}
renderMarkdown(content) {
if (typeof window !== 'undefined' && window.marked) {
try {
window.marked.setOptions({
breaks: true,
gfm: true,
sanitize: false,
});
let rendered = window.marked.parse(content);
rendered = this.wrapWordsInSpans(rendered);
return rendered;
} catch (error) {
console.warn('Error parsing markdown:', error);
return content;
}
}
return content;
}
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;
}
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;
container.scrollTop = Math.max(0, container.scrollTop - scrollAmount);
}
}
scrollResponseDown() {
const container = this.shadowRoot.querySelector('.response-container');
if (container) {
const scrollAmount = container.clientHeight * 0.3;
container.scrollTop = Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + scrollAmount);
}
}
connectedCallback() {
super.connectedCallback();
if (window.require) {
const { ipcRenderer } = window.require('electron');
this.handlePreviousResponse = () => this.navigateToPreviousResponse();
this.handleNextResponse = () => this.navigateToNextResponse();
this.handleScrollUp = () => this.scrollResponseUp();
this.handleScrollDown = () => this.scrollResponseDown();
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);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopWaveformAnimation();
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);
}
}
async handleSendText() {
const textInput = this.shadowRoot.querySelector('#textInput');
if (textInput && textInput.value.trim()) {
const message = textInput.value.trim();
textInput.value = '';
await this.onSendText(message);
}
}
handleTextKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.handleSendText();
}
}
async handleScreenAnswer() {
if (this.isAnalyzing) return;
if (window.captureManualScreenshot) {
this.isAnalyzing = true;
this._responseCountWhenStarted = this.responses.length;
window.captureManualScreenshot();
}
}
_startWaveformAnimation() {
const canvas = this.shadowRoot.querySelector('.analyze-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const dangerColor = getComputedStyle(this).getPropertyValue('--danger').trim() || '#EF4444';
const startTime = performance.now();
const FADE_IN = 0.5; // seconds
const PARTICLE_SPREAD = 4; // px inward from border
const PARTICLE_COUNT = 250;
// Pill perimeter helpers
const w = rect.width;
const h = rect.height;
const r = h / 2; // pill radius = half height
const straightLen = w - 2 * r;
const arcLen = Math.PI * r;
const perimeter = 2 * straightLen + 2 * arcLen;
// Given a distance along the perimeter, return {x, y, nx, ny} (position + inward normal)
const pointOnPerimeter = (d) => {
d = ((d % perimeter) + perimeter) % perimeter;
// Top straight: left to right
if (d < straightLen) {
return { x: r + d, y: 0, nx: 0, ny: 1 };
}
d -= straightLen;
// Right arc
if (d < arcLen) {
const angle = -Math.PI / 2 + (d / arcLen) * Math.PI;
return {
x: w - r + Math.cos(angle) * r,
y: r + Math.sin(angle) * r,
nx: -Math.cos(angle),
ny: -Math.sin(angle),
};
}
d -= arcLen;
// Bottom straight: right to left
if (d < straightLen) {
return { x: w - r - d, y: h, nx: 0, ny: -1 };
}
d -= straightLen;
// Left arc
const angle = Math.PI / 2 + (d / arcLen) * Math.PI;
return {
x: r + Math.cos(angle) * r,
y: r + Math.sin(angle) * r,
nx: -Math.cos(angle),
ny: -Math.sin(angle),
};
};
// Pre-seed random offsets for stable particles
const seeds = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
seeds.push({ pos: Math.random(), drift: Math.random(), depthSeed: Math.random() });
}
const draw = (now) => {
const elapsed = (now - startTime) / 1000;
const fade = Math.min(1, elapsed / FADE_IN);
ctx.clearRect(0, 0, w, h);
// ── Particle border ──
ctx.fillStyle = dangerColor;
for (let i = 0; i < PARTICLE_COUNT; i++) {
const s = seeds[i];
const along = (s.pos + s.drift * elapsed * 0.03) * perimeter;
const depth = s.depthSeed * PARTICLE_SPREAD;
const density = 1 - depth / PARTICLE_SPREAD;
if (Math.random() > density) continue;
const p = pointOnPerimeter(along);
const px = p.x + p.nx * depth;
const py = p.y + p.ny * depth;
const size = 0.8 + density * 0.6;
ctx.globalAlpha = fade * density * 0.85;
ctx.beginPath();
ctx.arc(px, py, size, 0, Math.PI * 2);
ctx.fill();
}
// ── Waveform ──
const midY = h / 2;
const waves = [
{ freq: 3, amp: 0.35, speed: 2.5, opacity: 0.9, width: 1.8 },
{ freq: 5, amp: 0.2, speed: 3.5, opacity: 0.5, width: 1.2 },
{ freq: 7, amp: 0.12, speed: 5, opacity: 0.3, width: 0.8 },
];
for (const wave of waves) {
ctx.beginPath();
ctx.strokeStyle = dangerColor;
ctx.globalAlpha = wave.opacity * fade;
ctx.lineWidth = wave.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let x = 0; x <= w; x++) {
const norm = x / w;
const envelope = Math.sin(norm * Math.PI);
const y = midY + Math.sin(norm * Math.PI * 2 * wave.freq + elapsed * wave.speed) * (midY * wave.amp) * envelope;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
this._animFrame = requestAnimationFrame(draw);
};
this._animFrame = requestAnimationFrame(draw);
}
_stopWaveformAnimation() {
if (this._animFrame) {
cancelAnimationFrame(this._animFrame);
this._animFrame = null;
}
const canvas = this.shadowRoot.querySelector('.analyze-canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
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();
}
if (changedProperties.has('isAnalyzing')) {
if (this.isAnalyzing) {
this._startWaveformAnimation();
} else {
this._stopWaveformAnimation();
}
}
if (changedProperties.has('responses') && this.isAnalyzing) {
if (this.responses.length > this._responseCountWhenStarted) {
this.isAnalyzing = false;
}
}
}
updateResponseContent() {
const container = this.shadowRoot.querySelector('#responseContainer');
if (container) {
const currentResponse = this.getCurrentResponse();
const renderedResponse = this.renderMarkdown(currentResponse);
container.innerHTML = renderedResponse;
if (this.shouldAnimateResponse) {
this.dispatchEvent(new CustomEvent('response-animation-complete', { bubbles: true, composed: true }));
}
}
}
render() {
const hasMultipleResponses = this.responses.length > 1;
return html`
<div class="response-container" id="responseContainer"></div>
${hasMultipleResponses ? html`
<div class="response-nav">
<button class="nav-btn" @click=${this.navigateToPreviousResponse} ?disabled=${this.currentResponseIndex <= 0} title="Previous response">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
</button>
<span class="response-counter">${this.currentResponseIndex + 1} of ${this.responses.length}</span>
<button class="nav-btn" @click=${this.navigateToNextResponse} ?disabled=${this.currentResponseIndex >= this.responses.length - 1} title="Next response">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
` : ''}
<div class="input-bar">
<div class="input-bar-inner">
<input
type="text"
id="textInput"
placeholder="Type a message..."
@keydown=${this.handleTextKeydown}
/>
</div>
<button class="analyze-btn ${this.isAnalyzing ? 'analyzing' : ''}" @click=${this.handleScreenAnswer}>
<canvas class="analyze-canvas"></canvas>
<span class="analyze-btn-content">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 3v7h6l-8 11v-7H5z" />
</svg>
Analyze Screen
</span>
</button>
</div>
`;
}
}
customElements.define('assistant-view', AssistantView);