2240 lines
90 KiB
JavaScript
2240 lines
90 KiB
JavaScript
document.addEventListener('DOMContentLoaded', function() {
|
||
const CONTEXT_PROFILES_STORAGE_KEY = 'contextProfiles';
|
||
const ACTIVE_CONTEXT_PROFILE_STORAGE_KEY = 'activeContextProfileId';
|
||
const CONTEXTS_BY_PROFILE_STORAGE_KEY = 'contextsByProfile';
|
||
const ACTIVE_AUTOMATION_STORAGE_KEY = 'activeAutomationId';
|
||
const DEFAULT_CONTEXT_PROFILE_ID = 'interview_software';
|
||
const DEFAULT_CONTEXT_PROFILES = [
|
||
{
|
||
id: 'interview_software',
|
||
name: 'Interview (Software Development)',
|
||
mode: 'interview',
|
||
systemPrompt: 'You are an interview assistant for software development. Keep responses concise, technically correct, and structured.'
|
||
},
|
||
{
|
||
id: 'meeting_standup',
|
||
name: 'Meeting (Daily Standup)',
|
||
mode: 'standup',
|
||
systemPrompt: 'You are a standup meeting assistant. Focus on updates, blockers, owners, and next steps.'
|
||
},
|
||
{
|
||
id: 'meeting_sales',
|
||
name: 'Meeting (Sales Call)',
|
||
mode: 'meeting',
|
||
systemPrompt: 'You are a sales call assistant. Focus on customer needs, objections, commitments, and clear follow-up actions.'
|
||
}
|
||
];
|
||
|
||
const toggleButton = document.getElementById('toggleListening');
|
||
const pauseButton = document.getElementById('pauseListening');
|
||
const sessionContextProfile = document.getElementById('sessionContextProfile');
|
||
const sessionContextHint = document.getElementById('sessionContextHint');
|
||
const sessionAutomationSelect = document.getElementById('sessionAutomationSelect');
|
||
const runSelectedAutomationNowBtn = document.getElementById('runSelectedAutomationNow');
|
||
const sessionAutomationHint = document.getElementById('sessionAutomationHint');
|
||
const storeSessionToggle = document.getElementById('storeSessionToggle');
|
||
const forgetSessionBtn = document.getElementById('forgetSession');
|
||
const transcriptDiv = document.getElementById('transcript');
|
||
const aiResponseDiv = document.getElementById('aiResponse');
|
||
const apiKeyInput = document.getElementById('apiKeyInput');
|
||
const saveApiKeyButton = document.getElementById('saveApiKey');
|
||
const aiProviderSelect = document.getElementById('aiProvider');
|
||
const modelSelect = document.getElementById('modelSelect');
|
||
const apiKeyStatus = document.getElementById('apiKeyStatus');
|
||
const sttProviderSelect = document.getElementById('sttProvider');
|
||
const sttModelSelect = document.getElementById('sttModel');
|
||
const sttLanguageModeSelect = document.getElementById('sttLanguageMode');
|
||
const sttForcedLanguageInput = document.getElementById('sttForcedLanguage');
|
||
const sttTaskSelect = document.getElementById('sttTask');
|
||
const sttVadFilterToggle = document.getElementById('sttVadFilter');
|
||
const sttBeamSizeInput = document.getElementById('sttBeamSize');
|
||
const sttEndpointInput = document.getElementById('sttEndpointInput');
|
||
const sttApiKeyInput = document.getElementById('sttApiKeyInput');
|
||
const saveSttApiKeyButton = document.getElementById('saveSttApiKey');
|
||
const testSttConnectionButton = document.getElementById('testSttConnection');
|
||
const sttStatus = document.getElementById('sttStatus');
|
||
const showOverlayBtn = document.getElementById('showOverlay');
|
||
const overlayStatus = document.getElementById('overlayStatus');
|
||
const requestMicPermissionBtn = document.getElementById('requestMicPermission');
|
||
const micPermissionStatus = document.getElementById('micPermissionStatus');
|
||
const grantTabAccessBtn = document.getElementById('grantTabAccess');
|
||
const tabAccessStatus = document.getElementById('tabAccessStatus');
|
||
const speedModeToggle = document.getElementById('speedModeToggle');
|
||
const captureModeSelect = document.getElementById('captureModeSelect');
|
||
const autoOpenAssistantWindowToggle = document.getElementById('autoOpenAssistantWindow');
|
||
const autoStartToggle = document.getElementById('autoStartToggle');
|
||
const inputDeviceSelect = document.getElementById('inputDeviceSelect');
|
||
const inputDeviceStatus = document.getElementById('inputDeviceStatus');
|
||
const micLevelBar = document.getElementById('micLevelBar');
|
||
const startMicMonitorBtn = document.getElementById('startMicMonitor');
|
||
|
||
// Context management elements
|
||
const contextFileInput = document.getElementById('contextFileInput');
|
||
const uploadContextBtn = document.getElementById('uploadContextBtn');
|
||
const contextTextInput = document.getElementById('contextTextInput');
|
||
const contextTypeSelect = document.getElementById('contextTypeSelect');
|
||
const contextTitleInput = document.getElementById('contextTitleInput');
|
||
const addContextBtn = document.getElementById('addContextBtn');
|
||
const contextList = document.getElementById('contextList');
|
||
const clearAllContextBtn = document.getElementById('clearAllContextBtn');
|
||
const contextProfileSelect = document.getElementById('contextProfileSelect');
|
||
const contextProfileHint = document.getElementById('contextProfileHint');
|
||
const profileNameInput = document.getElementById('profileNameInput');
|
||
const profileModeSelect = document.getElementById('profileModeSelect');
|
||
const profilePromptInput = document.getElementById('profilePromptInput');
|
||
const newProfileBtn = document.getElementById('newProfileBtn');
|
||
const saveProfileBtn = document.getElementById('saveProfileBtn');
|
||
const deleteProfileBtn = document.getElementById('deleteProfileBtn');
|
||
const profileManagerStatus = document.getElementById('profileManagerStatus');
|
||
|
||
// Multi-device elements
|
||
const enableRemoteListening = document.getElementById('enableRemoteListening');
|
||
const remoteStatus = document.getElementById('remoteStatus');
|
||
const deviceInfo = document.getElementById('deviceInfo');
|
||
const accessUrl = document.getElementById('accessUrl');
|
||
const copyUrlBtn = document.getElementById('copyUrlBtn');
|
||
const qrCode = document.getElementById('qrCode');
|
||
const openSettingsBtn = document.getElementById('openSettings');
|
||
|
||
let isListening = false;
|
||
let isPaused = false;
|
||
let remoteServerActive = false;
|
||
let contextProfiles = [...DEFAULT_CONTEXT_PROFILES];
|
||
let activeContextProfileId = DEFAULT_CONTEXT_PROFILE_ID;
|
||
let activeAutomationId = '';
|
||
let availableAutomations = [];
|
||
let draftProfileId = null;
|
||
let micMonitorStream = null;
|
||
let micMonitorCtx = null;
|
||
let micMonitorSource = null;
|
||
let micMonitorAnalyser = null;
|
||
let micMonitorRaf = null;
|
||
let editingContextId = null;
|
||
|
||
// AI Provider configurations
|
||
const aiProviders = {
|
||
openai: {
|
||
name: 'OpenAI',
|
||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||
defaultModel: 'gpt-4o-mini',
|
||
apiKeyPlaceholder: 'Enter your OpenAI API Key',
|
||
requiresKey: true
|
||
},
|
||
anthropic: {
|
||
name: 'Anthropic',
|
||
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'],
|
||
defaultModel: 'claude-3-5-sonnet-20241022',
|
||
apiKeyPlaceholder: 'Enter your Anthropic API Key',
|
||
requiresKey: true
|
||
},
|
||
google: {
|
||
name: 'Google',
|
||
models: ['gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-pro'],
|
||
defaultModel: 'gemini-1.5-flash',
|
||
apiKeyPlaceholder: 'Enter your Google AI API Key',
|
||
requiresKey: true
|
||
},
|
||
deepseek: {
|
||
name: 'DeepSeek',
|
||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||
defaultModel: 'deepseek-chat',
|
||
apiKeyPlaceholder: 'Enter your DeepSeek API Key',
|
||
requiresKey: true
|
||
},
|
||
ollama: {
|
||
name: 'Ollama',
|
||
models: ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'phi3'],
|
||
defaultModel: 'llama3.2',
|
||
apiKeyPlaceholder: 'No API key required (Local)',
|
||
requiresKey: false
|
||
}
|
||
};
|
||
const sttProviders = {
|
||
openai: {
|
||
name: 'OpenAI Whisper',
|
||
models: ['whisper-1'],
|
||
defaultModel: 'whisper-1',
|
||
requiresKey: true,
|
||
keyPlaceholder: 'Enter your OpenAI API key for transcription'
|
||
},
|
||
local: {
|
||
name: 'Local faster-whisper bridge',
|
||
models: ['small', 'medium', 'large-v3'],
|
||
defaultModel: 'small',
|
||
requiresKey: false,
|
||
keyPlaceholder: 'Optional local STT API key',
|
||
requiresEndpoint: true,
|
||
defaultEndpoint: 'http://localhost:8790/transcribe'
|
||
},
|
||
browser: {
|
||
name: 'Browser SpeechRecognition',
|
||
models: ['browser-default'],
|
||
defaultModel: 'browser-default',
|
||
requiresKey: false,
|
||
keyPlaceholder: 'No API key required'
|
||
}
|
||
};
|
||
const modelCache = {};
|
||
const modelFetchState = {};
|
||
|
||
// Load saved settings
|
||
chrome.storage.sync.get(['aiProvider', 'selectedModel', 'apiKeys', 'speedMode', 'captureMode', 'autoOpenAssistantWindow', 'inputDeviceId', 'autoStartEnabled', ACTIVE_AUTOMATION_STORAGE_KEY, 'sttProvider', 'sttModel', 'sttApiKeys', 'sttEndpoint', 'sttLanguageMode', 'sttForcedLanguage', 'sttTask', 'sttVadFilter', 'sttBeamSize'], (result) => {
|
||
const savedProvider = result.aiProvider || 'openai';
|
||
const savedModel = result.selectedModel || aiProviders[savedProvider].defaultModel;
|
||
const savedApiKeys = result.apiKeys || {};
|
||
const savedSttProvider = result.sttProvider || 'openai';
|
||
const savedSttModel = result.sttModel || sttProviders[savedSttProvider].defaultModel;
|
||
const savedSttApiKeys = result.sttApiKeys || {};
|
||
const savedSttEndpoint = result.sttEndpoint || sttProviders.local.defaultEndpoint;
|
||
const savedSttLanguageMode = result.sttLanguageMode || 'auto';
|
||
const savedSttForcedLanguage = result.sttForcedLanguage || '';
|
||
const savedSttTask = result.sttTask || 'transcribe';
|
||
const savedSttVadFilter = result.sttVadFilter !== false;
|
||
const savedSttBeamSize = Number(result.sttBeamSize) || 5;
|
||
const speedMode = Boolean(result.speedMode);
|
||
const captureMode = result.captureMode || 'tab';
|
||
const autoOpenAssistantWindow = Boolean(result.autoOpenAssistantWindow);
|
||
const savedInputDeviceId = result.inputDeviceId || '';
|
||
const autoStartEnabled = Boolean(result.autoStartEnabled);
|
||
activeAutomationId = result[ACTIVE_AUTOMATION_STORAGE_KEY] || '';
|
||
|
||
aiProviderSelect.value = savedProvider;
|
||
if (sttProviderSelect) sttProviderSelect.value = savedSttProvider;
|
||
if (sttModelSelect) sttModelSelect.value = savedSttModel;
|
||
if (sttLanguageModeSelect) sttLanguageModeSelect.value = savedSttLanguageMode;
|
||
if (sttForcedLanguageInput) sttForcedLanguageInput.value = savedSttForcedLanguage;
|
||
if (sttTaskSelect) sttTaskSelect.value = savedSttTask;
|
||
if (sttVadFilterToggle) sttVadFilterToggle.checked = savedSttVadFilter;
|
||
if (sttBeamSizeInput) sttBeamSizeInput.value = String(savedSttBeamSize);
|
||
if (captureModeSelect) captureModeSelect.value = captureMode;
|
||
if (speedModeToggle) speedModeToggle.checked = speedMode;
|
||
if (autoOpenAssistantWindowToggle) autoOpenAssistantWindowToggle.checked = autoOpenAssistantWindow;
|
||
if (autoStartToggle) autoStartToggle.checked = autoStartEnabled;
|
||
refreshModelOptions(savedProvider, savedModel, savedApiKeys[savedProvider]);
|
||
updateApiKeyInput(savedProvider);
|
||
updateSttSettingsUI(savedSttProvider, savedSttModel, savedSttApiKeys[savedSttProvider], savedSttEndpoint);
|
||
|
||
if (savedApiKeys[savedProvider] && aiProviders[savedProvider].requiresKey) {
|
||
apiKeyInput.value = savedApiKeys[savedProvider];
|
||
updateApiKeyStatus('API Key Saved', 'success');
|
||
saveApiKeyButton.textContent = 'API Key Saved';
|
||
saveApiKeyButton.disabled = true;
|
||
}
|
||
|
||
if (inputDeviceSelect) {
|
||
loadInputDevices(savedInputDeviceId);
|
||
}
|
||
loadAutomations();
|
||
});
|
||
|
||
function getModeLabel(mode) {
|
||
const labels = {
|
||
interview: 'Interview mode',
|
||
meeting: 'Meeting mode',
|
||
standup: 'Standup mode',
|
||
custom: 'Custom mode'
|
||
};
|
||
return labels[mode] || 'Custom mode';
|
||
}
|
||
|
||
function getActiveProfile() {
|
||
return contextProfiles.find((profile) => profile.id === activeContextProfileId) || contextProfiles[0];
|
||
}
|
||
|
||
function createProfileId() {
|
||
return `profile_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||
}
|
||
|
||
function updateProfileManagerStatus(message, type = '') {
|
||
if (!profileManagerStatus) return;
|
||
profileManagerStatus.textContent = message || '';
|
||
profileManagerStatus.className = `status-message ${type}`.trim();
|
||
}
|
||
|
||
function renderAutomationSelector() {
|
||
if (!sessionAutomationSelect) return;
|
||
sessionAutomationSelect.innerHTML = '';
|
||
|
||
const autoOption = document.createElement('option');
|
||
autoOption.value = '';
|
||
autoOption.textContent = 'Automatic (all matching automations)';
|
||
sessionAutomationSelect.appendChild(autoOption);
|
||
|
||
availableAutomations.forEach((automation) => {
|
||
const option = document.createElement('option');
|
||
option.value = automation.id;
|
||
option.textContent = automation.name || 'Untitled automation';
|
||
sessionAutomationSelect.appendChild(option);
|
||
});
|
||
|
||
const exists = availableAutomations.some((item) => item.id === activeAutomationId);
|
||
sessionAutomationSelect.value = exists ? activeAutomationId : '';
|
||
if (!exists) activeAutomationId = '';
|
||
updateAutomationHint();
|
||
}
|
||
|
||
function updateAutomationHint() {
|
||
if (!sessionAutomationHint) return;
|
||
if (!activeAutomationId) {
|
||
sessionAutomationHint.textContent = 'Session start/end uses all enabled automations that match trigger.';
|
||
return;
|
||
}
|
||
const selected = availableAutomations.find((item) => item.id === activeAutomationId);
|
||
if (!selected) {
|
||
sessionAutomationHint.textContent = 'Selected automation not found. Using automatic mode.';
|
||
return;
|
||
}
|
||
sessionAutomationHint.textContent = `Selected automation: ${selected.name || selected.id}`;
|
||
}
|
||
|
||
function loadAutomations() {
|
||
chrome.runtime.sendMessage({ action: 'automation:list' }, (response) => {
|
||
if (chrome.runtime.lastError) return;
|
||
const list = response && Array.isArray(response.automations) ? response.automations : [];
|
||
availableAutomations = list.filter((automation) => Boolean(automation.enabled));
|
||
renderAutomationSelector();
|
||
});
|
||
}
|
||
|
||
function handleAutomationSettingsChanged(changes, areaName) {
|
||
if (areaName !== 'sync') return;
|
||
if (!changes.advancedSettings && !changes[ACTIVE_AUTOMATION_STORAGE_KEY]) return;
|
||
if (changes[ACTIVE_AUTOMATION_STORAGE_KEY]) {
|
||
activeAutomationId = changes[ACTIVE_AUTOMATION_STORAGE_KEY].newValue || '';
|
||
}
|
||
loadAutomations();
|
||
}
|
||
|
||
function loadProfileEditor(profileId) {
|
||
if (!profileNameInput || !profileModeSelect || !profilePromptInput) return;
|
||
const profile = contextProfiles.find((item) => item.id === profileId);
|
||
if (!profile) return;
|
||
draftProfileId = profile.id;
|
||
profileNameInput.value = profile.name || '';
|
||
profileModeSelect.value = profile.mode || 'custom';
|
||
profilePromptInput.value = profile.systemPrompt || '';
|
||
}
|
||
|
||
function updateProfileHints() {
|
||
const profile = getActiveProfile();
|
||
if (!profile) return;
|
||
const modeText = getModeLabel(profile.mode);
|
||
const hint = `${modeText}. Prompt isolation is enabled for this profile.`;
|
||
if (sessionContextHint) sessionContextHint.textContent = hint;
|
||
if (contextProfileHint) contextProfileHint.textContent = `Editing contexts for: ${profile.name}`;
|
||
}
|
||
|
||
function renderProfileSelectors() {
|
||
if (!sessionContextProfile || !contextProfileSelect) return;
|
||
sessionContextProfile.innerHTML = '';
|
||
contextProfileSelect.innerHTML = '';
|
||
|
||
contextProfiles.forEach((profile) => {
|
||
const sessionOption = document.createElement('option');
|
||
sessionOption.value = profile.id;
|
||
sessionOption.textContent = profile.name;
|
||
if (profile.id === activeContextProfileId) {
|
||
sessionOption.selected = true;
|
||
}
|
||
sessionContextProfile.appendChild(sessionOption);
|
||
|
||
const contextOption = document.createElement('option');
|
||
contextOption.value = profile.id;
|
||
contextOption.textContent = profile.name;
|
||
if (profile.id === activeContextProfileId) {
|
||
contextOption.selected = true;
|
||
}
|
||
contextProfileSelect.appendChild(contextOption);
|
||
});
|
||
if (sessionContextProfile.value !== activeContextProfileId) {
|
||
sessionContextProfile.value = activeContextProfileId;
|
||
}
|
||
if (contextProfileSelect.value !== activeContextProfileId) {
|
||
contextProfileSelect.value = activeContextProfileId;
|
||
}
|
||
loadProfileEditor(activeContextProfileId);
|
||
updateProfileHints();
|
||
}
|
||
|
||
async function ensureContextProfiles() {
|
||
const result = await chrome.storage.sync.get([CONTEXT_PROFILES_STORAGE_KEY, ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]);
|
||
const savedProfiles = Array.isArray(result[CONTEXT_PROFILES_STORAGE_KEY]) && result[CONTEXT_PROFILES_STORAGE_KEY].length
|
||
? result[CONTEXT_PROFILES_STORAGE_KEY]
|
||
: DEFAULT_CONTEXT_PROFILES;
|
||
const preferredId = result[ACTIVE_CONTEXT_PROFILE_STORAGE_KEY] || savedProfiles[0].id || DEFAULT_CONTEXT_PROFILE_ID;
|
||
const resolvedId = savedProfiles.some((profile) => profile.id === preferredId) ? preferredId : savedProfiles[0].id;
|
||
|
||
contextProfiles = savedProfiles;
|
||
activeContextProfileId = resolvedId;
|
||
|
||
await chrome.storage.sync.set({
|
||
[CONTEXT_PROFILES_STORAGE_KEY]: savedProfiles,
|
||
[ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: resolvedId
|
||
});
|
||
renderProfileSelectors();
|
||
}
|
||
|
||
async function persistProfiles(nextProfiles, nextActiveProfileId) {
|
||
contextProfiles = nextProfiles;
|
||
activeContextProfileId = nextActiveProfileId;
|
||
await chrome.storage.sync.set({
|
||
[CONTEXT_PROFILES_STORAGE_KEY]: contextProfiles,
|
||
[ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: activeContextProfileId
|
||
});
|
||
renderProfileSelectors();
|
||
}
|
||
|
||
async function saveCurrentProfile() {
|
||
const name = profileNameInput ? profileNameInput.value.trim() : '';
|
||
const mode = profileModeSelect ? profileModeSelect.value : 'custom';
|
||
const systemPrompt = profilePromptInput ? profilePromptInput.value.trim() : '';
|
||
|
||
if (!name) {
|
||
updateProfileManagerStatus('Profile name is required.', 'error');
|
||
profileNameInput.focus();
|
||
return;
|
||
}
|
||
|
||
const targetId = draftProfileId || activeContextProfileId || createProfileId();
|
||
const existingIndex = contextProfiles.findIndex((profile) => profile.id === targetId);
|
||
const nextProfile = { id: targetId, name, mode, systemPrompt };
|
||
const nextProfiles = [...contextProfiles];
|
||
|
||
if (existingIndex >= 0) {
|
||
nextProfiles[existingIndex] = nextProfile;
|
||
} else {
|
||
nextProfiles.push(nextProfile);
|
||
}
|
||
|
||
await persistProfiles(nextProfiles, targetId);
|
||
updateProfileManagerStatus('Profile saved.', 'success');
|
||
}
|
||
|
||
async function createNewProfileDraft() {
|
||
const newProfile = {
|
||
id: createProfileId(),
|
||
name: 'New Context Profile',
|
||
mode: 'custom',
|
||
systemPrompt: ''
|
||
};
|
||
await persistProfiles([...contextProfiles, newProfile], newProfile.id);
|
||
updateProfileManagerStatus('New profile created. Update name and prompt, then click Save Profile.', '');
|
||
if (profileNameInput) {
|
||
profileNameInput.focus();
|
||
profileNameInput.select();
|
||
}
|
||
}
|
||
|
||
async function deleteActiveProfile() {
|
||
if (contextProfiles.length <= 1) {
|
||
updateProfileManagerStatus('At least one profile is required.', 'error');
|
||
return;
|
||
}
|
||
const current = getActiveProfile();
|
||
if (!current) return;
|
||
const confirmed = window.confirm(`Delete profile "${current.name}" and its scoped contexts?`);
|
||
if (!confirmed) return;
|
||
|
||
const nextProfiles = contextProfiles.filter((profile) => profile.id !== current.id);
|
||
const nextActive = nextProfiles[0].id;
|
||
await persistProfiles(nextProfiles, nextActive);
|
||
|
||
const localResult = await chrome.storage.local.get([CONTEXTS_BY_PROFILE_STORAGE_KEY]);
|
||
const byProfile = localResult[CONTEXTS_BY_PROFILE_STORAGE_KEY] || {};
|
||
if (byProfile[current.id]) {
|
||
delete byProfile[current.id];
|
||
await chrome.storage.local.set({ [CONTEXTS_BY_PROFILE_STORAGE_KEY]: byProfile });
|
||
}
|
||
|
||
await loadContexts();
|
||
updateProfileManagerStatus(`Deleted profile "${current.name}".`, 'success');
|
||
}
|
||
|
||
async function getContextsForProfile(profileId) {
|
||
const result = await chrome.storage.local.get([CONTEXTS_BY_PROFILE_STORAGE_KEY, 'contexts']);
|
||
const byProfile = result[CONTEXTS_BY_PROFILE_STORAGE_KEY] || {};
|
||
if (Array.isArray(byProfile[profileId])) {
|
||
return byProfile[profileId];
|
||
}
|
||
return result.contexts || [];
|
||
}
|
||
|
||
async function saveContextsForProfile(profileId, contexts) {
|
||
const result = await chrome.storage.local.get([CONTEXTS_BY_PROFILE_STORAGE_KEY, 'contexts']);
|
||
const byProfile = result[CONTEXTS_BY_PROFILE_STORAGE_KEY] || {};
|
||
byProfile[profileId] = contexts;
|
||
await chrome.storage.local.set({ [CONTEXTS_BY_PROFILE_STORAGE_KEY]: byProfile });
|
||
|
||
// Keep legacy key for compatibility with older paths.
|
||
if (profileId === DEFAULT_CONTEXT_PROFILE_ID) {
|
||
await chrome.storage.local.set({ contexts });
|
||
}
|
||
}
|
||
|
||
// Load and display saved contexts for the active profile.
|
||
ensureContextProfiles().then(loadContexts);
|
||
function setTranscriptText(text) {
|
||
if (transcriptDiv) transcriptDiv.textContent = text;
|
||
}
|
||
|
||
function setAIResponseText(text) {
|
||
if (aiResponseDiv) aiResponseDiv.textContent = text;
|
||
}
|
||
|
||
// Helper functions
|
||
function updateModelOptions(provider, selectedModel = null, modelsOverride = null) {
|
||
const models = modelsOverride || modelCache[provider] || aiProviders[provider].models;
|
||
modelSelect.innerHTML = '';
|
||
|
||
models.forEach(model => {
|
||
const option = document.createElement('option');
|
||
option.value = model;
|
||
option.textContent = model;
|
||
if (selectedModel === model || (!selectedModel && model === aiProviders[provider].defaultModel)) {
|
||
option.selected = true;
|
||
}
|
||
modelSelect.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function updateApiKeyInput(provider) {
|
||
const providerConfig = aiProviders[provider];
|
||
apiKeyInput.placeholder = providerConfig.apiKeyPlaceholder;
|
||
apiKeyInput.disabled = !providerConfig.requiresKey;
|
||
saveApiKeyButton.disabled = !providerConfig.requiresKey;
|
||
|
||
if (!providerConfig.requiresKey) {
|
||
apiKeyInput.value = '';
|
||
updateApiKeyStatus('No API key required', 'success');
|
||
} else {
|
||
updateApiKeyStatus('', '');
|
||
}
|
||
}
|
||
|
||
function updateApiKeyStatus(message, type) {
|
||
apiKeyStatus.textContent = message;
|
||
apiKeyStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function updateSttStatus(message, type) {
|
||
if (!sttStatus) return;
|
||
sttStatus.textContent = message;
|
||
sttStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function updateSttSettingsUI(provider, selectedModel, savedKey, savedEndpoint) {
|
||
if (!sttProviderSelect || !sttModelSelect || !sttApiKeyInput || !saveSttApiKeyButton) return;
|
||
const cfg = sttProviders[provider] || sttProviders.openai;
|
||
sttModelSelect.innerHTML = '';
|
||
cfg.models.forEach((model) => {
|
||
const option = document.createElement('option');
|
||
option.value = model;
|
||
option.textContent = model;
|
||
if (selectedModel === model || (!selectedModel && model === cfg.defaultModel)) {
|
||
option.selected = true;
|
||
}
|
||
sttModelSelect.appendChild(option);
|
||
});
|
||
sttModelSelect.disabled = provider === 'browser';
|
||
sttApiKeyInput.disabled = provider === 'browser';
|
||
sttApiKeyInput.placeholder = cfg.keyPlaceholder;
|
||
if (sttEndpointInput) {
|
||
sttEndpointInput.style.display = cfg.requiresEndpoint ? '' : 'none';
|
||
sttEndpointInput.value = savedEndpoint || cfg.defaultEndpoint || '';
|
||
}
|
||
if (sttForcedLanguageInput && sttLanguageModeSelect) {
|
||
sttForcedLanguageInput.disabled = sttLanguageModeSelect.value !== 'forced';
|
||
}
|
||
if (cfg.requiresKey) {
|
||
sttApiKeyInput.value = savedKey || '';
|
||
if (savedKey) {
|
||
saveSttApiKeyButton.textContent = 'STT API Key Saved';
|
||
saveSttApiKeyButton.disabled = true;
|
||
updateSttStatus('STT key saved. Tab/Mixed transcription is enabled.', 'success');
|
||
} else {
|
||
saveSttApiKeyButton.textContent = 'Save STT API Key';
|
||
saveSttApiKeyButton.disabled = false;
|
||
updateSttStatus('Tab/Mixed mode requires cloud STT API key.', '');
|
||
}
|
||
} else if (provider === 'local') {
|
||
sttApiKeyInput.value = savedKey || '';
|
||
saveSttApiKeyButton.textContent = savedKey ? 'Local STT Key Saved' : 'Save Local STT Key';
|
||
saveSttApiKeyButton.disabled = false;
|
||
updateSttStatus('Local STT active. Ensure local bridge server is running.', '');
|
||
} else {
|
||
sttApiKeyInput.value = '';
|
||
saveSttApiKeyButton.textContent = 'No API Key Needed';
|
||
saveSttApiKeyButton.disabled = true;
|
||
updateSttStatus('Browser STT is best for mic mode and may not capture tab speakers.', '');
|
||
}
|
||
}
|
||
|
||
function updateMicPermissionStatus(message, type) {
|
||
if (!micPermissionStatus) return;
|
||
micPermissionStatus.textContent = message;
|
||
micPermissionStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function updateOverlayStatus(message, type) {
|
||
if (!overlayStatus) return;
|
||
overlayStatus.textContent = message;
|
||
overlayStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function updateInputDeviceStatus(message, type) {
|
||
if (!inputDeviceStatus) return;
|
||
inputDeviceStatus.textContent = message;
|
||
inputDeviceStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function updateTabAccessStatus(message, type) {
|
||
if (!tabAccessStatus) return;
|
||
tabAccessStatus.textContent = message;
|
||
tabAccessStatus.className = `status-message ${type}`;
|
||
}
|
||
|
||
function pickModel(provider, preferredModel, models) {
|
||
if (preferredModel && models.includes(preferredModel)) {
|
||
return preferredModel;
|
||
}
|
||
if (aiProviders[provider].defaultModel && models.includes(aiProviders[provider].defaultModel)) {
|
||
return aiProviders[provider].defaultModel;
|
||
}
|
||
return models[0];
|
||
}
|
||
|
||
async function refreshModelOptions(provider, preferredModel, apiKey) {
|
||
if (modelFetchState[provider]) {
|
||
return;
|
||
}
|
||
|
||
modelSelect.disabled = true;
|
||
modelSelect.innerHTML = '<option>Loading models...</option>';
|
||
modelFetchState[provider] = true;
|
||
|
||
try {
|
||
let models = null;
|
||
|
||
if (provider === 'ollama') {
|
||
models = await fetchOllamaModels();
|
||
} else if (aiProviders[provider].requiresKey && apiKey) {
|
||
models = await fetchRemoteModels(provider, apiKey);
|
||
}
|
||
|
||
if (models && models.length) {
|
||
modelCache[provider] = models;
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Failed to fetch models for ${provider}:`, error);
|
||
} finally {
|
||
modelFetchState[provider] = false;
|
||
const availableModels = modelCache[provider] || aiProviders[provider].models;
|
||
const selected = pickModel(provider, preferredModel, availableModels);
|
||
updateModelOptions(provider, selected, availableModels);
|
||
chrome.storage.sync.set({ selectedModel: selected });
|
||
modelSelect.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadInputDevices(preferredDeviceId = '') {
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
|
||
updateInputDeviceStatus('Device enumeration is not supported in this browser.', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
const inputs = devices.filter(device => device.kind === 'audioinput');
|
||
const hasLabels = inputs.some(device => device.label);
|
||
inputDeviceSelect.innerHTML = '';
|
||
|
||
if (!inputs.length) {
|
||
const option = document.createElement('option');
|
||
option.value = '';
|
||
option.textContent = 'No input devices found';
|
||
inputDeviceSelect.appendChild(option);
|
||
inputDeviceSelect.disabled = true;
|
||
updateInputDeviceStatus('No microphone devices detected.', 'error');
|
||
return;
|
||
}
|
||
|
||
inputs.forEach((device, index) => {
|
||
const option = document.createElement('option');
|
||
option.value = device.deviceId;
|
||
option.textContent = device.label || `Microphone ${index + 1}`;
|
||
if (device.deviceId === preferredDeviceId) {
|
||
option.selected = true;
|
||
}
|
||
inputDeviceSelect.appendChild(option);
|
||
});
|
||
|
||
inputDeviceSelect.disabled = false;
|
||
const selectedOption = inputDeviceSelect.options[inputDeviceSelect.selectedIndex];
|
||
if (!hasLabels) {
|
||
updateInputDeviceStatus('Grant mic permission to see device names.', '');
|
||
} else {
|
||
updateInputDeviceStatus(`Selected: ${selectedOption ? selectedOption.textContent : 'Unknown'}`, '');
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to enumerate devices:', error);
|
||
updateInputDeviceStatus('Failed to list input devices.', 'error');
|
||
}
|
||
}
|
||
|
||
function stopMicMonitor() {
|
||
if (micMonitorRaf) {
|
||
cancelAnimationFrame(micMonitorRaf);
|
||
micMonitorRaf = null;
|
||
}
|
||
if (micMonitorSource) {
|
||
try {
|
||
micMonitorSource.disconnect();
|
||
} catch (error) {
|
||
console.warn('Failed to disconnect mic monitor source:', error);
|
||
}
|
||
micMonitorSource = null;
|
||
}
|
||
if (micMonitorAnalyser) {
|
||
try {
|
||
micMonitorAnalyser.disconnect();
|
||
} catch (error) {
|
||
console.warn('Failed to disconnect mic monitor analyser:', error);
|
||
}
|
||
micMonitorAnalyser = null;
|
||
}
|
||
if (micMonitorCtx) {
|
||
micMonitorCtx.close();
|
||
micMonitorCtx = null;
|
||
}
|
||
if (micMonitorStream) {
|
||
micMonitorStream.getTracks().forEach(track => track.stop());
|
||
micMonitorStream = null;
|
||
}
|
||
if (micLevelBar) {
|
||
micLevelBar.style.width = '0%';
|
||
}
|
||
}
|
||
|
||
async function startMicMonitor() {
|
||
if (!micLevelBar || !inputDeviceSelect) return;
|
||
stopMicMonitor();
|
||
updateInputDeviceStatus('Requesting microphone access...', '');
|
||
|
||
const deviceId = inputDeviceSelect.value;
|
||
const constraints = deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true };
|
||
|
||
try {
|
||
micMonitorStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||
micMonitorCtx = new AudioContext();
|
||
micMonitorAnalyser = micMonitorCtx.createAnalyser();
|
||
micMonitorAnalyser.fftSize = 512;
|
||
micMonitorAnalyser.smoothingTimeConstant = 0.8;
|
||
micMonitorSource = micMonitorCtx.createMediaStreamSource(micMonitorStream);
|
||
micMonitorSource.connect(micMonitorAnalyser);
|
||
|
||
const data = new Uint8Array(micMonitorAnalyser.fftSize);
|
||
const tick = () => {
|
||
if (!micMonitorAnalyser) return;
|
||
micMonitorAnalyser.getByteTimeDomainData(data);
|
||
let sum = 0;
|
||
for (let i = 0; i < data.length; i++) {
|
||
const v = (data[i] - 128) / 128;
|
||
sum += v * v;
|
||
}
|
||
const rms = Math.sqrt(sum / data.length);
|
||
const normalized = Math.min(1, rms * 2.5);
|
||
micLevelBar.style.width = `${Math.round(normalized * 100)}%`;
|
||
micMonitorRaf = requestAnimationFrame(tick);
|
||
};
|
||
|
||
micMonitorRaf = requestAnimationFrame(tick);
|
||
const selectedOption = inputDeviceSelect.options[inputDeviceSelect.selectedIndex];
|
||
updateInputDeviceStatus(`Mic monitor active: ${selectedOption ? selectedOption.textContent : 'Unknown'}`, 'success');
|
||
} catch (error) {
|
||
console.warn('Failed to start mic monitor:', error);
|
||
if (error && error.name === 'NotAllowedError') {
|
||
updateInputDeviceStatus('Microphone permission denied. Click "Request Microphone Permission".', 'error');
|
||
} else if (error && error.name === 'NotFoundError') {
|
||
updateInputDeviceStatus('No microphone found for the selected device.', 'error');
|
||
} else {
|
||
updateInputDeviceStatus('Microphone permission denied or unavailable.', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchRemoteModels(provider, apiKey) {
|
||
if (provider === 'openai') {
|
||
return fetchOpenAIModels(apiKey);
|
||
}
|
||
if (provider === 'anthropic') {
|
||
return fetchAnthropicModels(apiKey);
|
||
}
|
||
if (provider === 'google') {
|
||
return fetchGoogleModels(apiKey);
|
||
}
|
||
if (provider === 'deepseek') {
|
||
return fetchDeepSeekModels(apiKey);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function fetchOpenAIModels(apiKey) {
|
||
const response = await fetch('https://api.openai.com/v1/models', {
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`OpenAI models request failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const ids = (data.data || []).map((item) => item.id).filter(Boolean);
|
||
const chatModels = ids.filter((id) => (
|
||
id.startsWith('gpt-') ||
|
||
id.startsWith('o1') ||
|
||
id.startsWith('o3') ||
|
||
id.startsWith('o4') ||
|
||
id.startsWith('o5')
|
||
));
|
||
const models = chatModels.length ? chatModels : ids;
|
||
return Array.from(new Set(models)).sort();
|
||
}
|
||
|
||
async function fetchAnthropicModels(apiKey) {
|
||
const response = await fetch('https://api.anthropic.com/v1/models', {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': apiKey,
|
||
'anthropic-version': '2023-06-01'
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Anthropic models request failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const items = data.data || data.models || [];
|
||
const ids = items.map((item) => item.id || item.name).filter(Boolean);
|
||
return Array.from(new Set(ids)).sort();
|
||
}
|
||
|
||
async function fetchGoogleModels(apiKey) {
|
||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
|
||
if (!response.ok) {
|
||
throw new Error(`Google models request failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const models = (data.models || [])
|
||
.filter((model) => (model.supportedGenerationMethods || []).includes('generateContent'))
|
||
.map((model) => model.name || '')
|
||
.map((name) => name.replace(/^models\//, ''))
|
||
.filter(Boolean);
|
||
return Array.from(new Set(models)).sort();
|
||
}
|
||
|
||
async function fetchDeepSeekModels(apiKey) {
|
||
const response = await fetch('https://api.deepseek.com/v1/models', {
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`DeepSeek models request failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const ids = (data.data || []).map((item) => item.id).filter(Boolean);
|
||
return Array.from(new Set(ids)).sort();
|
||
}
|
||
|
||
async function fetchOllamaModels() {
|
||
const response = await fetch('http://localhost:11434/api/tags');
|
||
if (!response.ok) {
|
||
throw new Error(`Ollama models request failed: ${response.status}`);
|
||
}
|
||
const data = await response.json();
|
||
const models = (data.models || []).map((model) => model.name).filter(Boolean);
|
||
return Array.from(new Set(models)).sort();
|
||
}
|
||
|
||
// Context Management Functions
|
||
async function loadContexts() {
|
||
const contexts = await getContextsForProfile(activeContextProfileId);
|
||
displayContexts(contexts);
|
||
updateManageTabCount(contexts.length);
|
||
}
|
||
|
||
function displayContexts(contexts) {
|
||
contextList.innerHTML = '';
|
||
if (contexts.length === 0) {
|
||
contextList.innerHTML = '<div class="no-contexts">No context added yet. Add your CV or job description to get better responses!</div>';
|
||
return;
|
||
}
|
||
|
||
contexts.forEach((context, index) => {
|
||
const contextItem = document.createElement('div');
|
||
contextItem.className = 'context-item';
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'context-item-info';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'context-item-title';
|
||
title.textContent = context.title || 'Untitled context';
|
||
if (context.type) {
|
||
const typeBadge = document.createElement('span');
|
||
typeBadge.style.fontWeight = '400';
|
||
typeBadge.style.color = '#666';
|
||
typeBadge.textContent = ` • ${context.type}`;
|
||
title.appendChild(typeBadge);
|
||
}
|
||
|
||
const preview = document.createElement('div');
|
||
preview.className = 'context-item-preview';
|
||
const previewText = context.content || '';
|
||
preview.textContent = `${previewText.substring(0, 100)}${previewText.length > 100 ? '...' : ''}`;
|
||
|
||
info.appendChild(title);
|
||
info.appendChild(preview);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'context-item-actions';
|
||
|
||
const editButton = document.createElement('button');
|
||
editButton.className = 'edit-btn';
|
||
editButton.textContent = '✏️ Edit';
|
||
editButton.addEventListener('click', () => {
|
||
startEditingContext(index);
|
||
});
|
||
|
||
const deleteButton = document.createElement('button');
|
||
deleteButton.className = 'delete-btn danger-btn';
|
||
deleteButton.textContent = '🗑️ Delete';
|
||
deleteButton.addEventListener('click', () => {
|
||
deleteContext(index);
|
||
});
|
||
|
||
actions.appendChild(editButton);
|
||
actions.appendChild(deleteButton);
|
||
contextItem.appendChild(info);
|
||
contextItem.appendChild(actions);
|
||
contextList.appendChild(contextItem);
|
||
});
|
||
}
|
||
|
||
function updateManageTabCount(count) {
|
||
const manageTab = document.querySelector('[data-tab="manage"]');
|
||
manageTab.textContent = `Manage (${count})`;
|
||
}
|
||
|
||
async function saveContext(title, content) {
|
||
if (!title.trim() || !content.trim()) {
|
||
alert('Please provide both title and content');
|
||
return;
|
||
}
|
||
|
||
// Optional basic guard for extremely large items (>4MB)
|
||
const approxBytes = new Blob([content]).size;
|
||
if (approxBytes > 4 * 1024 * 1024) {
|
||
alert('This context is too large to store locally. Please split it into smaller parts.');
|
||
return;
|
||
}
|
||
|
||
const contexts = await getContextsForProfile(activeContextProfileId);
|
||
const nextType = (contextTypeSelect && contextTypeSelect.value) || 'general';
|
||
const nowIso = new Date().toISOString();
|
||
const existingIndex = editingContextId
|
||
? contexts.findIndex((context) => context.id === editingContextId)
|
||
: -1;
|
||
|
||
if (existingIndex >= 0) {
|
||
const existing = contexts[existingIndex];
|
||
contexts[existingIndex] = {
|
||
...existing,
|
||
title: title.trim(),
|
||
content: content.trim(),
|
||
type: nextType,
|
||
updatedAt: nowIso
|
||
};
|
||
} else {
|
||
contexts.push({
|
||
id: Date.now(),
|
||
title: title.trim(),
|
||
content: content.trim(),
|
||
type: nextType,
|
||
createdAt: nowIso
|
||
});
|
||
}
|
||
|
||
await saveContextsForProfile(activeContextProfileId, contexts);
|
||
loadContexts();
|
||
|
||
// Clear inputs
|
||
contextTitleInput.value = '';
|
||
contextTextInput.value = '';
|
||
if (contextTypeSelect) contextTypeSelect.value = 'general';
|
||
resetEditingContext();
|
||
|
||
// Switch to manage tab
|
||
switchTab('manage');
|
||
}
|
||
|
||
async function deleteContext(index) {
|
||
if (!confirm('Are you sure you want to delete this context?')) return;
|
||
|
||
const contexts = await getContextsForProfile(activeContextProfileId);
|
||
const target = contexts[index];
|
||
contexts.splice(index, 1);
|
||
|
||
await saveContextsForProfile(activeContextProfileId, contexts);
|
||
if (target && editingContextId && target.id === editingContextId) {
|
||
resetEditingContext();
|
||
}
|
||
loadContexts();
|
||
}
|
||
|
||
async function clearAllContexts() {
|
||
if (!confirm('Are you sure you want to delete all contexts? This cannot be undone.')) return;
|
||
|
||
await saveContextsForProfile(activeContextProfileId, []);
|
||
resetEditingContext();
|
||
loadContexts();
|
||
}
|
||
|
||
function resetEditingContext() {
|
||
editingContextId = null;
|
||
if (addContextBtn) {
|
||
addContextBtn.textContent = 'Save Context';
|
||
}
|
||
}
|
||
|
||
function switchTab(tabName) {
|
||
// Update tab buttons
|
||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
||
|
||
// Update tab content
|
||
document.querySelectorAll('.tab-content').forEach(content => {
|
||
content.classList.remove('active');
|
||
});
|
||
document.getElementById(`${tabName}Tab`).classList.add('active');
|
||
}
|
||
|
||
async function processFile(file) {
|
||
const lowerName = (file.name || '').toLowerCase();
|
||
let content = '';
|
||
|
||
if (file.type === 'text/plain' || lowerName.endsWith('.txt')) {
|
||
content = await readFileAsText(file);
|
||
} else if (file.type === 'application/pdf' || lowerName.endsWith('.pdf')) {
|
||
content = await extractPdfText(file);
|
||
} else if (
|
||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||
lowerName.endsWith('.docx')
|
||
) {
|
||
content = await extractDocxText(file);
|
||
} else if (
|
||
file.type === 'application/msword' ||
|
||
lowerName.endsWith('.doc')
|
||
) {
|
||
throw new Error('Legacy .doc is not supported yet. Please convert to .docx, .pdf, or .txt.');
|
||
} else {
|
||
throw new Error(`Unsupported file type: ${file.name}`);
|
||
}
|
||
|
||
if (!content || !content.trim()) {
|
||
throw new Error(`No readable text found in ${file.name}`);
|
||
}
|
||
|
||
return {
|
||
title: file.name,
|
||
content: content.trim()
|
||
};
|
||
}
|
||
|
||
function readFileAsText(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(String(e.target.result || ''));
|
||
reader.onerror = () => reject(new Error('Failed to read file as text'));
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
function readFileAsArrayBuffer(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(e.target.result);
|
||
reader.onerror = () => reject(new Error('Failed to read file bytes'));
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
}
|
||
|
||
async function extractPdfText(file) {
|
||
const buffer = await readFileAsArrayBuffer(file);
|
||
const bytes = new Uint8Array(buffer);
|
||
const latin = decodeBytes(bytes, 'latin1');
|
||
|
||
const parts = [];
|
||
const seen = new Set();
|
||
const pushPart = (value) => {
|
||
const normalized = normalizeExtractedText(value);
|
||
if (!normalized) return;
|
||
if (seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
parts.push(normalized);
|
||
};
|
||
|
||
// Heuristic 1: Extract UTF-16BE hex strings often embedded in metadata.
|
||
const utf16HexRegex = /<FEFF([0-9A-Fa-f]{8,})>/g;
|
||
let utf16Match;
|
||
while ((utf16Match = utf16HexRegex.exec(latin)) !== null) {
|
||
const decoded = decodeUtf16BeHex(utf16Match[1]);
|
||
if (decoded) pushPart(decoded);
|
||
}
|
||
|
||
// Heuristic 2: Extract decoded /ecv-data JSON if present.
|
||
const ecvDataMatch = latin.match(/\/ecv-data\s*<FEFF([0-9A-Fa-f]+)>/);
|
||
if (ecvDataMatch && ecvDataMatch[1]) {
|
||
const decodedJson = decodeUtf16BeHex(ecvDataMatch[1]);
|
||
if (decodedJson) {
|
||
try {
|
||
const obj = JSON.parse(decodedJson);
|
||
if (isLikelyEnhancvPayload(obj)) {
|
||
return buildEnhancvResumeText(obj);
|
||
}
|
||
pushPart(flattenJsonText(obj));
|
||
} catch (_) {
|
||
pushPart(decodedJson);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Heuristic 3: Decompress FlateDecode streams and extract readable strings.
|
||
const streamRegex = /<<[\s\S]*?\/Filter\s*\/FlateDecode[\s\S]*?>>\s*stream\r?\n/g;
|
||
let streamMatch;
|
||
while ((streamMatch = streamRegex.exec(latin)) !== null) {
|
||
const streamStart = streamMatch.index + streamMatch[0].length;
|
||
const endStreamIndex = latin.indexOf('\nendstream', streamStart);
|
||
if (endStreamIndex === -1) continue;
|
||
|
||
const compressed = bytes.slice(streamStart, endStreamIndex);
|
||
let decompressed;
|
||
try {
|
||
decompressed = await inflateBytes(compressed, 'deflate');
|
||
} catch (_) {
|
||
try {
|
||
decompressed = await inflateBytes(compressed, 'deflate-raw');
|
||
} catch (_) {
|
||
decompressed = null;
|
||
}
|
||
}
|
||
if (!decompressed) continue;
|
||
|
||
const decompressedText = decodeBytes(decompressed, 'latin1');
|
||
extractPdfLikeStrings(decompressedText).forEach(pushPart);
|
||
}
|
||
|
||
// Heuristic 4: Pull literal strings from uncompressed body.
|
||
extractPdfLikeStrings(latin).forEach(pushPart);
|
||
|
||
return cleanPdfAggregate(parts.join('\n\n'));
|
||
}
|
||
|
||
async function extractDocxText(file) {
|
||
const buffer = await readFileAsArrayBuffer(file);
|
||
const bytes = new Uint8Array(buffer);
|
||
const entries = parseZipEntries(bytes);
|
||
|
||
const xmlTargets = entries
|
||
.filter((entry) => /^word\/(document|header\d+|footer\d+|footnotes)\.xml$/i.test(entry.name))
|
||
.sort((a, b) => {
|
||
const order = (name) => {
|
||
if (name === 'word/document.xml') return 0;
|
||
if (name.startsWith('word/header')) return 1;
|
||
if (name.startsWith('word/footer')) return 2;
|
||
return 3;
|
||
};
|
||
return order(a.name) - order(b.name) || a.name.localeCompare(b.name);
|
||
});
|
||
|
||
if (xmlTargets.length === 0) {
|
||
throw new Error('Could not find readable DOCX XML content');
|
||
}
|
||
|
||
const sections = [];
|
||
for (const entry of xmlTargets) {
|
||
const xmlBytes = await unzipEntry(bytes, entry);
|
||
const xml = decodeBytes(xmlBytes, 'utf-8');
|
||
const text = extractTextFromDocxXml(xml);
|
||
if (text) {
|
||
sections.push(text);
|
||
}
|
||
}
|
||
|
||
return sections.join('\n\n');
|
||
}
|
||
|
||
function decodeBytes(bytes, encoding) {
|
||
return new TextDecoder(encoding, { fatal: false }).decode(bytes);
|
||
}
|
||
|
||
function decodeUtf16BeHex(hex) {
|
||
const cleanHex = hex.replace(/[^0-9A-Fa-f]/g, '');
|
||
if (cleanHex.length < 4 || cleanHex.length % 2 !== 0) {
|
||
return '';
|
||
}
|
||
const raw = new Uint8Array(cleanHex.length / 2);
|
||
for (let i = 0; i < cleanHex.length; i += 2) {
|
||
raw[i / 2] = Number.parseInt(cleanHex.slice(i, i + 2), 16);
|
||
}
|
||
return decodeBytes(raw, 'utf-16be');
|
||
}
|
||
|
||
function normalizeExtractedText(value) {
|
||
if (!value) return '';
|
||
const text = String(value)
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\u0000/g, '')
|
||
.replace(/[ \t]+\n/g, '\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
if (!text) return '';
|
||
|
||
const printable = (text.match(/[A-Za-z0-9]/g) || []).length;
|
||
if (printable < 8) return '';
|
||
if (/^node\d{6,}$/i.test(text)) return '';
|
||
if (/[<5B>]/.test(text)) return '';
|
||
if (/^[A-Za-z0-9+/=]{40,}$/.test(text)) return '';
|
||
return text;
|
||
}
|
||
|
||
function decodePdfLiteralString(value) {
|
||
let out = '';
|
||
for (let i = 0; i < value.length; i++) {
|
||
const ch = value[i];
|
||
if (ch !== '\\') {
|
||
out += ch;
|
||
continue;
|
||
}
|
||
const next = value[++i];
|
||
if (next === undefined) break;
|
||
if (next === 'n') out += '\n';
|
||
else if (next === 'r') out += '\r';
|
||
else if (next === 't') out += '\t';
|
||
else if (next === 'b') out += '\b';
|
||
else if (next === 'f') out += '\f';
|
||
else if (next === '(' || next === ')' || next === '\\') out += next;
|
||
else if (/[0-7]/.test(next)) {
|
||
let octal = next;
|
||
for (let j = 0; j < 2; j++) {
|
||
if (i + 1 < value.length && /[0-7]/.test(value[i + 1])) {
|
||
octal += value[++i];
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
out += String.fromCharCode(Number.parseInt(octal, 8));
|
||
} else {
|
||
out += next;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function extractPdfLikeStrings(source) {
|
||
const candidates = [];
|
||
|
||
const literalRegex = /\(([^()\r\n]{3,})\)/g;
|
||
let literalMatch;
|
||
while ((literalMatch = literalRegex.exec(source)) !== null) {
|
||
candidates.push(decodePdfLiteralString(literalMatch[1]));
|
||
}
|
||
|
||
const utf16HexRegex = /<FEFF([0-9A-Fa-f]{8,})>/g;
|
||
let hexMatch;
|
||
while ((hexMatch = utf16HexRegex.exec(source)) !== null) {
|
||
candidates.push(decodeUtf16BeHex(hexMatch[1]));
|
||
}
|
||
|
||
return candidates;
|
||
}
|
||
|
||
async function inflateBytes(data, format) {
|
||
if (typeof DecompressionStream !== 'function') {
|
||
throw new Error('DecompressionStream is unavailable');
|
||
}
|
||
const stream = new Response(data).body.pipeThrough(new DecompressionStream(format));
|
||
const result = await new Response(stream).arrayBuffer();
|
||
return new Uint8Array(result);
|
||
}
|
||
|
||
function flattenJsonText(input) {
|
||
const chunks = [];
|
||
const walk = (value) => {
|
||
if (value == null) return;
|
||
if (typeof value === 'string') {
|
||
const cleaned = value.replace(/<[^>]+>/g, ' ').trim();
|
||
if (cleaned) chunks.push(cleaned);
|
||
return;
|
||
}
|
||
if (Array.isArray(value)) {
|
||
value.forEach(walk);
|
||
return;
|
||
}
|
||
if (typeof value === 'object') {
|
||
Object.values(value).forEach(walk);
|
||
}
|
||
};
|
||
walk(input);
|
||
return chunks.join('\n');
|
||
}
|
||
|
||
function isLikelyEnhancvPayload(obj) {
|
||
if (!obj || typeof obj !== 'object') return false;
|
||
if (!Array.isArray(obj.sections)) return false;
|
||
if (!obj.header || typeof obj.header !== 'object') return false;
|
||
return Boolean(obj.header.name || obj.header.title || obj.header.email);
|
||
}
|
||
|
||
function buildEnhancvResumeText(data) {
|
||
const lines = [];
|
||
const header = data.header || {};
|
||
const push = (line = '') => {
|
||
const clean = cleanInline(line);
|
||
if (!clean && line !== '') return;
|
||
lines.push(clean);
|
||
};
|
||
|
||
const formatDateRange = (range) => {
|
||
if (!range || typeof range !== 'object') return '';
|
||
const from = formatMonthYear(range.fromMonth, range.fromYear);
|
||
const to = range.isOngoing ? 'Present' : formatMonthYear(range.toMonth, range.toYear);
|
||
if (from && to) return `${from} - ${to}`;
|
||
return from || to || '';
|
||
};
|
||
|
||
push(header.name || data.Title || 'Resume');
|
||
if (header.title) push(header.title);
|
||
const contact = [header.email, header.phone, header.location].filter(Boolean).join(' | ');
|
||
if (contact) push(contact);
|
||
if (header.link) push(header.link);
|
||
|
||
for (const section of data.sections || []) {
|
||
const sectionType = section.__t || '';
|
||
const sectionName = cleanInline(section.name || sectionType.replace(/Section$/, ''));
|
||
if (sectionName) {
|
||
push('');
|
||
push(sectionName.toUpperCase());
|
||
}
|
||
|
||
if (Array.isArray(section.items)) {
|
||
for (const item of section.items) {
|
||
if (!item || typeof item !== 'object') continue;
|
||
|
||
if (sectionType === 'ExperienceSection') {
|
||
const role = cleanInline(item.position);
|
||
const company = cleanInline(item.workplace);
|
||
const location = cleanInline(item.location);
|
||
const dateRange = formatDateRange(item.dateRange);
|
||
|
||
const heading = [role, company].filter(Boolean).join(' @ ');
|
||
if (heading) push(heading);
|
||
const meta = [location, dateRange].filter(Boolean).join(' | ');
|
||
if (meta) push(meta);
|
||
|
||
if (Array.isArray(item.bullets)) {
|
||
item.bullets
|
||
.map(cleanInline)
|
||
.filter(Boolean)
|
||
.forEach((bullet) => push(`- ${bullet}`));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (sectionType === 'EducationSection') {
|
||
const degree = cleanInline(item.degree);
|
||
const institution = cleanInline(item.institution);
|
||
const location = cleanInline(item.location);
|
||
const dateRange = formatDateRange(item.dateRange);
|
||
const heading = [degree, institution].filter(Boolean).join(' - ');
|
||
if (heading) push(heading);
|
||
const meta = [location, dateRange].filter(Boolean).join(' | ');
|
||
if (meta) push(meta);
|
||
continue;
|
||
}
|
||
|
||
if (sectionType === 'SummarySection') {
|
||
push(cleanInline(item.text));
|
||
continue;
|
||
}
|
||
|
||
if (sectionType === 'LanguageSection') {
|
||
const name = cleanInline(item.name);
|
||
const level = cleanInline(item.levelText);
|
||
if (name || level) push([name, level].filter(Boolean).join(' - '));
|
||
continue;
|
||
}
|
||
|
||
if (sectionType === 'TechnologySection') {
|
||
if (Array.isArray(item.tags)) {
|
||
push(item.tags.map(cleanInline).filter(Boolean).join(', '));
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (Array.isArray(item.bullets)) {
|
||
item.bullets
|
||
.map(cleanInline)
|
||
.filter(Boolean)
|
||
.forEach((bullet) => push(`- ${bullet}`));
|
||
} else if (item.title || item.description) {
|
||
push([cleanInline(item.title), cleanInline(item.description)].filter(Boolean).join(': '));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return cleanPdfAggregate(lines.join('\n'));
|
||
}
|
||
|
||
function formatMonthYear(month, year) {
|
||
if (!year && year !== 0) return '';
|
||
const y = String(year);
|
||
if (month == null || month < 0 || month > 11) return y;
|
||
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
return `${monthNames[month]} ${y}`;
|
||
}
|
||
|
||
function cleanInline(value) {
|
||
if (value == null) return '';
|
||
let text = String(value);
|
||
text = text
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/div>/gi, '\n')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/\u0000/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
text = decodeXmlEntities(text);
|
||
text = text.replace(/\s+([,.;:!?])/g, '$1');
|
||
return text;
|
||
}
|
||
|
||
function cleanPdfAggregate(value) {
|
||
if (!value) return '';
|
||
const rawLines = String(value).replace(/\r\n/g, '\n').split('\n');
|
||
const out = [];
|
||
let previousBlank = false;
|
||
|
||
for (const raw of rawLines) {
|
||
const line = raw.trim();
|
||
if (!line) {
|
||
if (!previousBlank && out.length > 0) {
|
||
out.push('');
|
||
previousBlank = true;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (isPdfNoiseLine(line)) continue;
|
||
out.push(line);
|
||
previousBlank = false;
|
||
}
|
||
|
||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
function isPdfNoiseLine(line) {
|
||
if (/^%PDF-/i.test(line)) return true;
|
||
if (/^\d+\s+\d+\s+obj$/i.test(line)) return true;
|
||
if (/^(endobj|stream|endstream|xref|trailer|startxref)$/i.test(line)) return true;
|
||
if (/^node\d{6,}$/i.test(line)) return true;
|
||
if (/^\/[A-Za-z][A-Za-z0-9]+/.test(line)) return true;
|
||
if (/^[A-Za-z0-9+/=]{50,}$/.test(line)) return true;
|
||
if (/[<5B>]/.test(line)) return true;
|
||
if (!/[A-Za-z]/.test(line) && line.length > 45) return true;
|
||
const odd = (line.match(/[^A-Za-z0-9 .,;:!?'"\-_/()[\]@&%+]/g) || []).length;
|
||
if (line.length >= 30 && odd / line.length > 0.35) return true;
|
||
return false;
|
||
}
|
||
|
||
function parseZipEntries(bytes) {
|
||
const eocd = findZipEndOfCentralDirectory(bytes);
|
||
if (eocd < 0) {
|
||
throw new Error('Invalid DOCX file (EOCD not found)');
|
||
}
|
||
|
||
const centralDirSize = readUint32LE(bytes, eocd + 12);
|
||
const centralDirOffset = readUint32LE(bytes, eocd + 16);
|
||
const centralDirEnd = centralDirOffset + centralDirSize;
|
||
const entries = [];
|
||
|
||
let cursor = centralDirOffset;
|
||
while (cursor + 46 <= centralDirEnd && cursor + 46 <= bytes.length) {
|
||
if (readUint32LE(bytes, cursor) !== 0x02014b50) {
|
||
break;
|
||
}
|
||
|
||
const compression = readUint16LE(bytes, cursor + 10);
|
||
const compressedSize = readUint32LE(bytes, cursor + 20);
|
||
const fileNameLength = readUint16LE(bytes, cursor + 28);
|
||
const extraLength = readUint16LE(bytes, cursor + 30);
|
||
const commentLength = readUint16LE(bytes, cursor + 32);
|
||
const localHeaderOffset = readUint32LE(bytes, cursor + 42);
|
||
|
||
const nameStart = cursor + 46;
|
||
const nameEnd = nameStart + fileNameLength;
|
||
const name = decodeBytes(bytes.slice(nameStart, nameEnd), 'utf-8');
|
||
|
||
entries.push({
|
||
name,
|
||
compression,
|
||
compressedSize,
|
||
localHeaderOffset
|
||
});
|
||
|
||
cursor = nameEnd + extraLength + commentLength;
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
function findZipEndOfCentralDirectory(bytes) {
|
||
const minIndex = Math.max(0, bytes.length - 0xffff - 22);
|
||
for (let i = bytes.length - 22; i >= minIndex; i--) {
|
||
if (readUint32LE(bytes, i) === 0x06054b50) {
|
||
return i;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
async function unzipEntry(zipBytes, entry) {
|
||
const offset = entry.localHeaderOffset;
|
||
if (readUint32LE(zipBytes, offset) !== 0x04034b50) {
|
||
throw new Error(`Invalid local header for ${entry.name}`);
|
||
}
|
||
|
||
const fileNameLength = readUint16LE(zipBytes, offset + 26);
|
||
const extraLength = readUint16LE(zipBytes, offset + 28);
|
||
const dataStart = offset + 30 + fileNameLength + extraLength;
|
||
const dataEnd = dataStart + entry.compressedSize;
|
||
const compressed = zipBytes.slice(dataStart, dataEnd);
|
||
|
||
if (entry.compression === 0) {
|
||
return compressed;
|
||
}
|
||
if (entry.compression === 8) {
|
||
try {
|
||
return await inflateBytes(compressed, 'deflate-raw');
|
||
} catch (_) {
|
||
return await inflateBytes(compressed, 'deflate');
|
||
}
|
||
}
|
||
throw new Error(`Unsupported DOCX compression method (${entry.compression}) for ${entry.name}`);
|
||
}
|
||
|
||
function readUint16LE(bytes, offset) {
|
||
return bytes[offset] | (bytes[offset + 1] << 8);
|
||
}
|
||
|
||
function readUint32LE(bytes, offset) {
|
||
return (
|
||
bytes[offset] |
|
||
(bytes[offset + 1] << 8) |
|
||
(bytes[offset + 2] << 16) |
|
||
(bytes[offset + 3] << 24)
|
||
) >>> 0;
|
||
}
|
||
|
||
function extractTextFromDocxXml(xml) {
|
||
if (!xml) return '';
|
||
let text = xml;
|
||
text = text.replace(/<w:tab\/>/g, '\t');
|
||
text = text.replace(/<w:br\/>/g, '\n');
|
||
text = text.replace(/<w:cr\/>/g, '\n');
|
||
text = text.replace(/<\/w:p>/g, '\n');
|
||
text = text.replace(/<\/w:tr>/g, '\n');
|
||
text = text.replace(/<\/w:tc>/g, '\t');
|
||
text = text.replace(/<[^>]+>/g, '');
|
||
text = decodeXmlEntities(text);
|
||
text = text
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/[ \t]+\n/g, '\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
return text;
|
||
}
|
||
|
||
function decodeXmlEntities(value) {
|
||
return value
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||
.replace(/&#([0-9]+);/g, (_, num) => String.fromCodePoint(Number.parseInt(num, 10)));
|
||
}
|
||
|
||
// Multi-device functions
|
||
async function enableRemoteAccess() {
|
||
try {
|
||
remoteStatus.textContent = 'Starting server...';
|
||
remoteStatus.className = 'status-message';
|
||
|
||
// Generate a unique session ID
|
||
const sessionId = Math.random().toString(36).substring(2, 15);
|
||
const port = 8765;
|
||
const accessURL = `http://localhost:${port}?session=${sessionId}`;
|
||
|
||
// Start WebSocket server (we'll implement this)
|
||
chrome.runtime.sendMessage({
|
||
action: 'startRemoteServer',
|
||
sessionId: sessionId,
|
||
port: port
|
||
}, (response) => {
|
||
if (response.success) {
|
||
remoteServerActive = true;
|
||
accessUrl.textContent = accessURL;
|
||
deviceInfo.style.display = 'block';
|
||
remoteStatus.textContent = 'Remote access enabled!';
|
||
remoteStatus.className = 'status-message success';
|
||
enableRemoteListening.textContent = '🛑 Disable Remote Access';
|
||
|
||
// Generate QR code (simplified)
|
||
generateQRCode(accessURL);
|
||
} else {
|
||
remoteStatus.textContent = 'Failed to start server: ' + response.error;
|
||
remoteStatus.className = 'status-message error';
|
||
}
|
||
});
|
||
} catch (error) {
|
||
remoteStatus.textContent = 'Error: ' + error.message;
|
||
remoteStatus.className = 'status-message error';
|
||
}
|
||
}
|
||
|
||
function disableRemoteAccess() {
|
||
chrome.runtime.sendMessage({ action: 'stopRemoteServer' }, (response) => {
|
||
remoteServerActive = false;
|
||
deviceInfo.style.display = 'none';
|
||
remoteStatus.textContent = '';
|
||
enableRemoteListening.textContent = '🌐 Enable Remote Access';
|
||
});
|
||
}
|
||
|
||
function generateQRCode(url) {
|
||
// Simple QR code placeholder - in production, use a QR code library
|
||
qrCode.innerHTML = `
|
||
<div style="border: 2px solid #333; padding: 10px; display: inline-block;">
|
||
<div style="font-size: 8px; font-family: monospace;">QR Code</div>
|
||
<div style="font-size: 6px;">Scan to access</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Make functions available globally for onclick handlers
|
||
function startEditingContext(index) {
|
||
getContextsForProfile(activeContextProfileId).then((contexts) => {
|
||
const context = contexts[index];
|
||
if (context) {
|
||
editingContextId = context.id;
|
||
contextTitleInput.value = context.title;
|
||
contextTextInput.value = context.content;
|
||
if (contextTypeSelect) contextTypeSelect.value = context.type || 'general';
|
||
if (addContextBtn) addContextBtn.textContent = 'Update Context';
|
||
switchTab('text');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Event listeners
|
||
aiProviderSelect.addEventListener('change', function() {
|
||
const selectedProvider = this.value;
|
||
updateApiKeyInput(selectedProvider);
|
||
|
||
// Load saved API key for this provider
|
||
chrome.storage.sync.get('apiKeys', (result) => {
|
||
const apiKeys = result.apiKeys || {};
|
||
if (apiKeys[selectedProvider] && aiProviders[selectedProvider].requiresKey) {
|
||
apiKeyInput.value = apiKeys[selectedProvider];
|
||
updateApiKeyStatus('API Key Saved', 'success');
|
||
saveApiKeyButton.textContent = 'API Key Saved';
|
||
saveApiKeyButton.disabled = true;
|
||
} else {
|
||
apiKeyInput.value = '';
|
||
saveApiKeyButton.textContent = 'Save API Key';
|
||
saveApiKeyButton.disabled = !aiProviders[selectedProvider].requiresKey;
|
||
}
|
||
|
||
refreshModelOptions(selectedProvider, aiProviders[selectedProvider].defaultModel, apiKeys[selectedProvider]);
|
||
});
|
||
|
||
// Save provider selection
|
||
chrome.storage.sync.set({
|
||
aiProvider: selectedProvider
|
||
});
|
||
});
|
||
|
||
modelSelect.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ selectedModel: this.value });
|
||
});
|
||
|
||
if (sttProviderSelect) {
|
||
sttProviderSelect.addEventListener('change', function() {
|
||
const provider = this.value;
|
||
chrome.storage.sync.get(['sttApiKeys'], (result) => {
|
||
const sttApiKeys = result.sttApiKeys || {};
|
||
const model = sttProviders[provider].defaultModel;
|
||
chrome.storage.sync.set({ sttProvider: provider, sttModel: model }, () => {
|
||
chrome.storage.sync.get(['sttEndpoint'], (saved) => {
|
||
updateSttSettingsUI(provider, model, sttApiKeys[provider], saved.sttEndpoint || sttProviders.local.defaultEndpoint);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (sttModelSelect) {
|
||
sttModelSelect.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ sttModel: this.value });
|
||
});
|
||
}
|
||
|
||
if (sttLanguageModeSelect) {
|
||
sttLanguageModeSelect.addEventListener('change', function() {
|
||
const mode = this.value;
|
||
if (sttForcedLanguageInput) {
|
||
sttForcedLanguageInput.disabled = mode !== 'forced';
|
||
}
|
||
chrome.storage.sync.set({ sttLanguageMode: mode });
|
||
});
|
||
}
|
||
|
||
if (sttForcedLanguageInput) {
|
||
sttForcedLanguageInput.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ sttForcedLanguage: this.value.trim().toLowerCase() });
|
||
});
|
||
}
|
||
|
||
if (sttTaskSelect) {
|
||
sttTaskSelect.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ sttTask: this.value });
|
||
});
|
||
}
|
||
|
||
if (sttVadFilterToggle) {
|
||
sttVadFilterToggle.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ sttVadFilter: this.checked });
|
||
});
|
||
}
|
||
|
||
if (sttBeamSizeInput) {
|
||
sttBeamSizeInput.addEventListener('change', function() {
|
||
const next = Math.min(10, Math.max(1, Number(this.value) || 5));
|
||
this.value = String(next);
|
||
chrome.storage.sync.set({ sttBeamSize: next });
|
||
});
|
||
}
|
||
|
||
if (sttApiKeyInput) {
|
||
sttApiKeyInput.addEventListener('input', function() {
|
||
if (!sttProviderSelect || (sttProviderSelect.value !== 'openai' && sttProviderSelect.value !== 'local')) return;
|
||
saveSttApiKeyButton.textContent = 'Save STT API Key';
|
||
saveSttApiKeyButton.disabled = false;
|
||
updateSttStatus('', '');
|
||
});
|
||
}
|
||
|
||
if (saveSttApiKeyButton) {
|
||
saveSttApiKeyButton.addEventListener('click', function() {
|
||
if (!sttProviderSelect) return;
|
||
const provider = sttProviderSelect.value;
|
||
if (provider !== 'openai' && provider !== 'local') return;
|
||
const apiKey = (sttApiKeyInput ? sttApiKeyInput.value : '').trim();
|
||
if (!apiKey && provider === 'openai') {
|
||
updateSttStatus('Please enter a valid STT API key.', 'error');
|
||
return;
|
||
}
|
||
chrome.storage.sync.get('sttApiKeys', (result) => {
|
||
const sttApiKeys = result.sttApiKeys || {};
|
||
if (apiKey) {
|
||
sttApiKeys[provider] = apiKey;
|
||
} else {
|
||
delete sttApiKeys[provider];
|
||
}
|
||
chrome.storage.sync.set({ sttApiKeys }, () => {
|
||
saveSttApiKeyButton.textContent = provider === 'local' ? 'Local STT Key Saved' : 'STT API Key Saved';
|
||
saveSttApiKeyButton.disabled = true;
|
||
updateSttStatus(provider === 'local' ? 'Local STT key saved.' : 'STT API key saved.', 'success');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (sttEndpointInput) {
|
||
sttEndpointInput.addEventListener('change', function() {
|
||
const endpoint = this.value.trim();
|
||
chrome.storage.sync.set({ sttEndpoint: endpoint }, () => {
|
||
if (sttProviderSelect && sttProviderSelect.value === 'local') {
|
||
updateSttStatus('Local STT endpoint saved.', 'success');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (testSttConnectionButton) {
|
||
testSttConnectionButton.addEventListener('click', function() {
|
||
updateSttStatus('Testing STT connection...', '');
|
||
chrome.runtime.sendMessage({ action: 'stt:testConnection' }, (response) => {
|
||
if (chrome.runtime.lastError || !response) {
|
||
updateSttStatus('STT connection test failed.', 'error');
|
||
return;
|
||
}
|
||
if (!response.success) {
|
||
updateSttStatus(response.error || 'STT connection test failed.', 'error');
|
||
return;
|
||
}
|
||
updateSttStatus(response.message || 'STT connection is healthy.', 'success');
|
||
});
|
||
});
|
||
}
|
||
|
||
if (captureModeSelect) {
|
||
captureModeSelect.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ captureMode: this.value });
|
||
});
|
||
}
|
||
|
||
if (autoOpenAssistantWindowToggle) {
|
||
autoOpenAssistantWindowToggle.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ autoOpenAssistantWindow: this.checked });
|
||
});
|
||
}
|
||
|
||
if (autoStartToggle) {
|
||
autoStartToggle.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ autoStartEnabled: this.checked });
|
||
});
|
||
}
|
||
|
||
if (inputDeviceSelect) {
|
||
inputDeviceSelect.addEventListener('change', function() {
|
||
const deviceId = this.value;
|
||
chrome.storage.sync.set({ inputDeviceId: deviceId });
|
||
const selectedOption = inputDeviceSelect.options[inputDeviceSelect.selectedIndex];
|
||
updateInputDeviceStatus(`Selected: ${selectedOption ? selectedOption.textContent : 'Unknown'}`, '');
|
||
if (micMonitorStream) {
|
||
startMicMonitor();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (startMicMonitorBtn) {
|
||
startMicMonitorBtn.addEventListener('click', function() {
|
||
startMicMonitor();
|
||
});
|
||
updateInputDeviceStatus('Click \"Enable Mic Monitor\" to see live input level.', '');
|
||
}
|
||
|
||
if (openSettingsBtn) {
|
||
openSettingsBtn.addEventListener('click', function() {
|
||
chrome.runtime.sendMessage({ action: 'openSettingsWindow' });
|
||
});
|
||
}
|
||
|
||
if (sessionAutomationSelect) {
|
||
sessionAutomationSelect.addEventListener('change', async function() {
|
||
activeAutomationId = this.value || '';
|
||
await chrome.storage.sync.set({ [ACTIVE_AUTOMATION_STORAGE_KEY]: activeAutomationId });
|
||
updateAutomationHint();
|
||
});
|
||
}
|
||
|
||
if (runSelectedAutomationNowBtn) {
|
||
runSelectedAutomationNowBtn.addEventListener('click', function() {
|
||
if (sessionAutomationHint) {
|
||
sessionAutomationHint.textContent = 'Running automation...';
|
||
sessionAutomationHint.className = 'status-message';
|
||
}
|
||
chrome.runtime.sendMessage(
|
||
{ action: 'automation:run', trigger: 'manual', automationId: activeAutomationId || null },
|
||
(response) => {
|
||
if (chrome.runtime.lastError || !response || !response.success) {
|
||
if (sessionAutomationHint) {
|
||
sessionAutomationHint.textContent = response && response.error ? response.error : 'Automation run failed.';
|
||
sessionAutomationHint.className = 'status-message error';
|
||
}
|
||
return;
|
||
}
|
||
if (sessionAutomationHint) {
|
||
const count = Array.isArray(response.results) ? response.results.length : 1;
|
||
sessionAutomationHint.textContent = `Automation completed (${count} result${count === 1 ? '' : 's'}).`;
|
||
sessionAutomationHint.className = 'status-message success';
|
||
}
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
if (sessionContextProfile) {
|
||
sessionContextProfile.addEventListener('change', async function() {
|
||
activeContextProfileId = this.value;
|
||
if (contextProfileSelect) contextProfileSelect.value = this.value;
|
||
await chrome.storage.sync.set({ [ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: activeContextProfileId });
|
||
resetEditingContext();
|
||
updateProfileHints();
|
||
loadProfileEditor(activeContextProfileId);
|
||
loadContexts();
|
||
});
|
||
}
|
||
|
||
if (contextProfileSelect) {
|
||
contextProfileSelect.addEventListener('change', async function() {
|
||
activeContextProfileId = this.value;
|
||
if (sessionContextProfile) sessionContextProfile.value = this.value;
|
||
await chrome.storage.sync.set({ [ACTIVE_CONTEXT_PROFILE_STORAGE_KEY]: activeContextProfileId });
|
||
resetEditingContext();
|
||
updateProfileHints();
|
||
loadProfileEditor(activeContextProfileId);
|
||
loadContexts();
|
||
});
|
||
}
|
||
|
||
if (newProfileBtn) {
|
||
newProfileBtn.addEventListener('click', function() {
|
||
createNewProfileDraft();
|
||
});
|
||
}
|
||
|
||
if (saveProfileBtn) {
|
||
saveProfileBtn.addEventListener('click', function() {
|
||
saveCurrentProfile();
|
||
});
|
||
}
|
||
|
||
if (deleteProfileBtn) {
|
||
deleteProfileBtn.addEventListener('click', function() {
|
||
deleteActiveProfile();
|
||
});
|
||
}
|
||
|
||
if (speedModeToggle) {
|
||
speedModeToggle.addEventListener('change', function() {
|
||
chrome.storage.sync.set({ speedMode: this.checked });
|
||
});
|
||
}
|
||
|
||
apiKeyInput.addEventListener('input', function() {
|
||
if (aiProviders[aiProviderSelect.value].requiresKey) {
|
||
saveApiKeyButton.textContent = 'Save API Key';
|
||
saveApiKeyButton.disabled = false;
|
||
updateApiKeyStatus('', '');
|
||
}
|
||
});
|
||
|
||
saveApiKeyButton.addEventListener('click', function() {
|
||
const apiKey = apiKeyInput.value.trim();
|
||
const provider = aiProviderSelect.value;
|
||
|
||
if (!aiProviders[provider].requiresKey) {
|
||
return;
|
||
}
|
||
|
||
if (apiKey) {
|
||
// Save API key for the current provider
|
||
chrome.storage.sync.get('apiKeys', (result) => {
|
||
const apiKeys = result.apiKeys || {};
|
||
apiKeys[provider] = apiKey;
|
||
|
||
chrome.storage.sync.set({ apiKeys: apiKeys }, () => {
|
||
saveApiKeyButton.textContent = 'API Key Saved';
|
||
saveApiKeyButton.disabled = true;
|
||
updateApiKeyStatus('API Key Saved', 'success');
|
||
refreshModelOptions(provider, modelSelect.value, apiKey);
|
||
});
|
||
});
|
||
} else {
|
||
updateApiKeyStatus('Please enter a valid API key', 'error');
|
||
}
|
||
});
|
||
|
||
// Context management event listeners
|
||
document.querySelectorAll('.tab-button').forEach(button => {
|
||
button.addEventListener('click', function() {
|
||
const tabName = this.getAttribute('data-tab');
|
||
switchTab(tabName);
|
||
});
|
||
});
|
||
|
||
uploadContextBtn.addEventListener('click', function() {
|
||
contextFileInput.click();
|
||
});
|
||
|
||
contextFileInput.addEventListener('change', async function() {
|
||
const files = Array.from(this.files);
|
||
for (const file of files) {
|
||
try {
|
||
const result = await processFile(file);
|
||
await saveContext(result.title, result.content);
|
||
} catch (error) {
|
||
alert('Error processing file: ' + error.message);
|
||
}
|
||
}
|
||
this.value = ''; // Clear file input
|
||
});
|
||
|
||
addContextBtn.addEventListener('click', function() {
|
||
const title = contextTitleInput.value.trim();
|
||
const content = contextTextInput.value.trim();
|
||
saveContext(title, content);
|
||
});
|
||
|
||
clearAllContextBtn.addEventListener('click', clearAllContexts);
|
||
|
||
// Multi-device event listeners
|
||
enableRemoteListening.addEventListener('click', function() {
|
||
if (remoteServerActive) {
|
||
disableRemoteAccess();
|
||
} else {
|
||
enableRemoteAccess();
|
||
}
|
||
});
|
||
|
||
copyUrlBtn.addEventListener('click', function() {
|
||
navigator.clipboard.writeText(accessUrl.textContent).then(() => {
|
||
const originalText = copyUrlBtn.textContent;
|
||
copyUrlBtn.textContent = '✅ Copied!';
|
||
setTimeout(() => {
|
||
copyUrlBtn.textContent = originalText;
|
||
}, 2000);
|
||
});
|
||
});
|
||
|
||
toggleButton.addEventListener('click', function() {
|
||
isListening = !isListening;
|
||
toggleButton.textContent = isListening ? 'Stop Listening' : 'Start Listening';
|
||
|
||
if (isListening) {
|
||
// Send current AI configuration with start listening
|
||
const currentProvider = aiProviderSelect.value;
|
||
const currentModel = modelSelect.value;
|
||
const captureMode = captureModeSelect ? captureModeSelect.value : 'tab';
|
||
|
||
chrome.runtime.sendMessage({
|
||
action: 'startListening',
|
||
aiProvider: currentProvider,
|
||
model: currentModel,
|
||
captureMode: captureMode,
|
||
contextProfileId: activeContextProfileId,
|
||
automationId: activeAutomationId || null
|
||
});
|
||
const consent = window.confirm('Store this session to memory? You can forget it later.');
|
||
chrome.runtime.sendMessage({ action: 'session:setConsent', consent });
|
||
setTranscriptText('Listening for questions...');
|
||
setAIResponseText(`Using ${aiProviders[currentProvider].name} (${currentModel}). The answer will appear here.`);
|
||
if (storeSessionToggle) {
|
||
storeSessionToggle.disabled = false;
|
||
storeSessionToggle.checked = consent;
|
||
}
|
||
if (forgetSessionBtn) {
|
||
forgetSessionBtn.disabled = false;
|
||
}
|
||
if (pauseButton) {
|
||
pauseButton.disabled = false;
|
||
pauseButton.textContent = 'Pause Listening';
|
||
}
|
||
isPaused = false;
|
||
chrome.storage.sync.get(['autoOpenAssistantWindow'], (result) => {
|
||
if (result.autoOpenAssistantWindow) {
|
||
chrome.runtime.sendMessage({ action: 'openAssistantWindow' });
|
||
}
|
||
});
|
||
} else {
|
||
chrome.runtime.sendMessage({action: 'stopListening'});
|
||
setTranscriptText('');
|
||
setAIResponseText('');
|
||
if (storeSessionToggle) {
|
||
storeSessionToggle.disabled = true;
|
||
storeSessionToggle.checked = false;
|
||
}
|
||
if (forgetSessionBtn) {
|
||
forgetSessionBtn.disabled = true;
|
||
}
|
||
if (pauseButton) {
|
||
pauseButton.disabled = true;
|
||
pauseButton.textContent = 'Pause Listening';
|
||
}
|
||
isPaused = false;
|
||
}
|
||
});
|
||
|
||
if (pauseButton) {
|
||
pauseButton.addEventListener('click', function() {
|
||
if (!isListening) return;
|
||
if (!isPaused) {
|
||
chrome.runtime.sendMessage({ action: 'pauseListening' });
|
||
pauseButton.textContent = 'Resume Listening';
|
||
isPaused = true;
|
||
setTranscriptText('Paused.');
|
||
} else {
|
||
const currentProvider = aiProviderSelect.value;
|
||
const currentModel = modelSelect.value;
|
||
const captureMode = captureModeSelect ? captureModeSelect.value : 'tab';
|
||
chrome.runtime.sendMessage({
|
||
action: 'startListening',
|
||
aiProvider: currentProvider,
|
||
model: currentModel,
|
||
captureMode: captureMode,
|
||
contextProfileId: activeContextProfileId,
|
||
automationId: activeAutomationId || null
|
||
});
|
||
pauseButton.textContent = 'Pause Listening';
|
||
isPaused = false;
|
||
setTranscriptText('Listening for questions...');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (storeSessionToggle) {
|
||
storeSessionToggle.addEventListener('change', function() {
|
||
if (!isListening) {
|
||
storeSessionToggle.checked = false;
|
||
return;
|
||
}
|
||
if (!storeSessionToggle.checked) {
|
||
const confirmForget = window.confirm('Turn off storage and forget this session?');
|
||
if (!confirmForget) {
|
||
storeSessionToggle.checked = true;
|
||
return;
|
||
}
|
||
chrome.runtime.sendMessage({ action: 'session:forgetCurrent' });
|
||
chrome.runtime.sendMessage({ action: 'session:setConsent', consent: false });
|
||
setTranscriptText('');
|
||
setAIResponseText('Session forgotten. Listening continues without storage.');
|
||
} else {
|
||
chrome.runtime.sendMessage({ action: 'session:setConsent', consent: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
if (forgetSessionBtn) {
|
||
forgetSessionBtn.addEventListener('click', function() {
|
||
if (!window.confirm('Forget this session and clear stored data?')) return;
|
||
chrome.runtime.sendMessage({ action: 'session:forgetCurrent' });
|
||
if (isListening) {
|
||
chrome.runtime.sendMessage({ action: 'stopListening' });
|
||
}
|
||
isListening = false;
|
||
isPaused = false;
|
||
toggleButton.textContent = 'Start Listening';
|
||
setTranscriptText('');
|
||
setAIResponseText('');
|
||
if (pauseButton) {
|
||
pauseButton.disabled = true;
|
||
pauseButton.textContent = 'Pause Listening';
|
||
}
|
||
if (storeSessionToggle) {
|
||
storeSessionToggle.disabled = true;
|
||
storeSessionToggle.checked = false;
|
||
}
|
||
if (forgetSessionBtn) {
|
||
forgetSessionBtn.disabled = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (requestMicPermissionBtn) {
|
||
requestMicPermissionBtn.addEventListener('click', function() {
|
||
updateMicPermissionStatus('Requesting microphone permission...', '');
|
||
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
|
||
stream.getTracks().forEach(track => track.stop());
|
||
updateMicPermissionStatus('Microphone permission granted.', 'success');
|
||
if (inputDeviceSelect) {
|
||
loadInputDevices(inputDeviceSelect.value);
|
||
}
|
||
}).catch((error) => {
|
||
if (error && error.name === 'NotAllowedError') {
|
||
updateMicPermissionStatus('Microphone permission denied. Please allow access for the extension.', 'error');
|
||
} else if (error && error.name === 'NotFoundError') {
|
||
updateMicPermissionStatus('No microphone found.', 'error');
|
||
} else {
|
||
updateMicPermissionStatus(error && error.message ? error.message : 'Failed to request microphone permission.', 'error');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (grantTabAccessBtn) {
|
||
grantTabAccessBtn.addEventListener('click', function() {
|
||
updateTabAccessStatus('Requesting tab access...', '');
|
||
chrome.runtime.sendMessage({ action: 'grantTabAccess' }, (response) => {
|
||
if (chrome.runtime.lastError) {
|
||
updateTabAccessStatus('Failed to request tab access. Click the extension icon on the target tab.', 'error');
|
||
return;
|
||
}
|
||
if (response && response.success) {
|
||
updateTabAccessStatus('Tab access granted. You can start listening now.', 'success');
|
||
} else {
|
||
updateTabAccessStatus(response && response.error ? response.error : 'Click the extension icon on the target tab to grant access.', 'error');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
if (showOverlayBtn) {
|
||
showOverlayBtn.addEventListener('click', function() {
|
||
updateOverlayStatus('Restoring transcription overlay...', '');
|
||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||
if (chrome.runtime.lastError || !tabs.length) {
|
||
updateOverlayStatus('No active tab found.', 'error');
|
||
return;
|
||
}
|
||
chrome.tabs.sendMessage(tabs[0].id, { action: 'showOverlay' }, () => {
|
||
if (chrome.runtime.lastError) {
|
||
updateOverlayStatus('Overlay unavailable on this tab. Start listening first.', 'error');
|
||
return;
|
||
}
|
||
updateOverlayStatus('Overlay restored.', 'success');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
|
||
navigator.mediaDevices.addEventListener('devicechange', () => {
|
||
if (inputDeviceSelect) {
|
||
loadInputDevices(inputDeviceSelect.value);
|
||
}
|
||
});
|
||
}
|
||
|
||
chrome.storage.onChanged.addListener(handleAutomationSettingsChanged);
|
||
window.addEventListener('focus', loadAutomations);
|
||
|
||
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
|
||
if (request.action === 'updateTranscript') {
|
||
setTranscriptText(request.transcript);
|
||
} else if (request.action === 'updateAIResponse') {
|
||
setAIResponseText(request.response);
|
||
} else if (request.action === 'automation:needsApproval') {
|
||
const actionsList = Array.isArray(request.actions)
|
||
? request.actions.map((action) => `• ${action.label} (${action.toolName})`).join('\\n')
|
||
: '';
|
||
const title = request.automationName ? `Automation: ${request.automationName}` : 'Automation';
|
||
const confirmMessage = `Run automation actions now?\\n\\n${title}\\nTrigger: ${request.trigger}\\n${actionsList}`;
|
||
const approved = window.confirm(confirmMessage);
|
||
chrome.runtime.sendMessage({ action: approved ? 'automation:approve' : 'automation:reject' }, (response) => {
|
||
if (chrome.runtime.lastError || !response) return;
|
||
if (response.success && approved) {
|
||
setAIResponseText('Automation approved and executed.');
|
||
} else if (response.success && !approved) {
|
||
setAIResponseText('Automation declined.');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|