App.vue•49.1 kB
<template>
<div class="popup-container">
<div class="header">
<div class="header-content">
<h1 class="header-title">Chrome MCP Server</h1>
</div>
</div>
<div class="content">
<div class="section">
<h2 class="section-title">{{ getMessage('nativeServerConfigLabel') }}</h2>
<div class="config-card">
<div class="status-section">
<div class="status-header">
<p class="status-label">{{ getMessage('runningStatusLabel') }}</p>
<button
class="refresh-status-button"
@click="refreshServerStatus"
:title="getMessage('refreshStatusButton')"
>
🔄
</button>
</div>
<div class="status-info">
<span :class="['status-dot', getStatusClass()]"></span>
<span class="status-text">{{ getStatusText() }}</span>
</div>
<div v-if="serverStatus.lastUpdated" class="status-timestamp">
{{ getMessage('lastUpdatedLabel') }}
{{ new Date(serverStatus.lastUpdated).toLocaleTimeString() }}
</div>
</div>
<div v-if="showMcpConfig" class="mcp-config-section">
<div class="mcp-config-header">
<p class="mcp-config-label">{{ getMessage('mcpServerConfigLabel') }}</p>
<button class="copy-config-button" @click="copyMcpConfig">
{{ copyButtonText }}
</button>
</div>
<div class="mcp-config-content">
<pre class="mcp-config-json">{{ mcpConfigJson }}</pre>
</div>
</div>
<div class="port-section">
<label for="port" class="port-label">{{ getMessage('connectionPortLabel') }}</label>
<input
type="text"
id="port"
:value="nativeServerPort"
@input="updatePort"
class="port-input"
/>
</div>
<button class="connect-button" :disabled="isConnecting" @click="testNativeConnection">
<BoltIcon />
<span>{{
isConnecting
? getMessage('connectingStatus')
: nativeConnectionStatus === 'connected'
? getMessage('disconnectButton')
: getMessage('connectButton')
}}</span>
</button>
</div>
</div>
<div class="section">
<h2 class="section-title">{{ getMessage('semanticEngineLabel') }}</h2>
<div class="semantic-engine-card">
<div class="semantic-engine-status">
<div class="status-info">
<span :class="['status-dot', getSemanticEngineStatusClass()]"></span>
<span class="status-text">{{ getSemanticEngineStatusText() }}</span>
</div>
<div v-if="semanticEngineLastUpdated" class="status-timestamp">
{{ getMessage('lastUpdatedLabel') }}
{{ new Date(semanticEngineLastUpdated).toLocaleTimeString() }}
</div>
</div>
<ProgressIndicator
v-if="isSemanticEngineInitializing"
:visible="isSemanticEngineInitializing"
:text="semanticEngineInitProgress"
:showSpinner="true"
/>
<button
class="semantic-engine-button"
:disabled="isSemanticEngineInitializing"
@click="initializeSemanticEngine"
>
<BoltIcon />
<span>{{ getSemanticEngineButtonText() }}</span>
</button>
</div>
</div>
<div class="section">
<h2 class="section-title">{{ getMessage('embeddingModelLabel') }}</h2>
<ProgressIndicator
v-if="isModelSwitching || isModelDownloading"
:visible="isModelSwitching || isModelDownloading"
:text="getProgressText()"
:showSpinner="true"
/>
<div v-if="modelInitializationStatus === 'error'" class="error-card">
<div class="error-content">
<div class="error-icon">⚠️</div>
<div class="error-details">
<p class="error-title">{{ getMessage('semanticEngineInitFailedStatus') }}</p>
<p class="error-message">{{
modelErrorMessage || getMessage('semanticEngineInitFailedStatus')
}}</p>
<p class="error-suggestion">{{ getErrorTypeText() }}</p>
</div>
</div>
<button
class="retry-button"
@click="retryModelInitialization"
:disabled="isModelSwitching || isModelDownloading"
>
<span>🔄</span>
<span>{{ getMessage('retryButton') }}</span>
</button>
</div>
<div class="model-list">
<div
v-for="model in availableModels"
:key="model.preset"
:class="[
'model-card',
{
selected: currentModel === model.preset,
disabled: isModelSwitching || isModelDownloading,
},
]"
@click="
!isModelSwitching && !isModelDownloading && switchModel(model.preset as ModelPreset)
"
>
<div class="model-header">
<div class="model-info">
<p class="model-name" :class="{ 'selected-text': currentModel === model.preset }">
{{ model.preset }}
</p>
<p class="model-description">{{ getModelDescription(model) }}</p>
</div>
<div v-if="currentModel === model.preset" class="check-icon">
<CheckIcon class="text-white" />
</div>
</div>
<div class="model-tags">
<span class="model-tag performance">{{ getPerformanceText(model.performance) }}</span>
<span class="model-tag size">{{ model.size }}</span>
<span class="model-tag dimension">{{ model.dimension }}D</span>
</div>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">{{ getMessage('indexDataManagementLabel') }}</h2>
<div class="stats-grid">
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('indexedPagesLabel') }}</p>
<span class="stats-icon violet">
<DocumentIcon />
</span>
</div>
<p class="stats-value">{{ storageStats?.indexedPages || 0 }}</p>
</div>
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('indexSizeLabel') }}</p>
<span class="stats-icon teal">
<DatabaseIcon />
</span>
</div>
<p class="stats-value">{{ formatIndexSize() }}</p>
</div>
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('activeTabsLabel') }}</p>
<span class="stats-icon blue">
<TabIcon />
</span>
</div>
<p class="stats-value">{{ getActiveTabsCount() }}</p>
</div>
<div class="stats-card">
<div class="stats-header">
<p class="stats-label">{{ getMessage('vectorDocumentsLabel') }}</p>
<span class="stats-icon green">
<VectorIcon />
</span>
</div>
<p class="stats-value">{{ storageStats?.totalDocuments || 0 }}</p>
</div>
</div>
<ProgressIndicator
v-if="isClearingData && clearDataProgress"
:visible="isClearingData"
:text="clearDataProgress"
:showSpinner="true"
/>
<button
class="danger-button"
:disabled="isClearingData"
@click="showClearConfirmation = true"
>
<TrashIcon />
<span>{{ isClearingData ? getMessage('clearingStatus') : getMessage('clearAllDataButton') }}</span>
</button>
</div>
<!-- Model Cache Management Section -->
<ModelCacheManagement
:cache-stats="cacheStats"
:is-managing-cache="isManagingCache"
@cleanup-cache="cleanupCache"
@clear-all-cache="clearAllCache"
/>
</div>
<div class="footer">
<p class="footer-text">chrome mcp server for ai</p>
</div>
<ConfirmDialog
:visible="showClearConfirmation"
:title="getMessage('confirmClearDataTitle')"
:message="getMessage('clearDataWarningMessage')"
:items="[
getMessage('clearDataList1'),
getMessage('clearDataList2'),
getMessage('clearDataList3'),
]"
:warning="getMessage('clearDataIrreversibleWarning')"
icon="⚠️"
:confirm-text="getMessage('confirmClearButton')"
:cancel-text="getMessage('cancelButton')"
:confirming-text="getMessage('clearingStatus')"
:is-confirming="isClearingData"
@confirm="confirmClearAllData"
@cancel="hideClearDataConfirmation"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import {
PREDEFINED_MODELS,
type ModelPreset,
getModelInfo,
getCacheStats,
clearModelCache,
cleanupModelCache,
} from '@/utils/semantic-similarity-engine';
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
import { getMessage } from '@/utils/i18n';
import ConfirmDialog from './components/ConfirmDialog.vue';
import ProgressIndicator from './components/ProgressIndicator.vue';
import ModelCacheManagement from './components/ModelCacheManagement.vue';
import {
DocumentIcon,
DatabaseIcon,
BoltIcon,
TrashIcon,
CheckIcon,
TabIcon,
VectorIcon,
} from './components/icons';
const nativeConnectionStatus = ref<'unknown' | 'connected' | 'disconnected'>('unknown');
const isConnecting = ref(false);
const nativeServerPort = ref<number>(12306);
const serverStatus = ref<{
isRunning: boolean;
port?: number;
lastUpdated: number;
}>({
isRunning: false,
lastUpdated: Date.now(),
});
const showMcpConfig = computed(() => {
return nativeConnectionStatus.value === 'connected' && serverStatus.value.isRunning;
});
const copyButtonText = ref(getMessage('copyConfigButton'));
const mcpConfigJson = computed(() => {
const port = serverStatus.value.port || nativeServerPort.value;
const config = {
mcpServers: {
'streamable-mcp-server': {
type: 'streamable-http',
url: `http://127.0.0.1:${port}/mcp`,
},
},
};
return JSON.stringify(config, null, 2);
});
const currentModel = ref<ModelPreset | null>(null);
const isModelSwitching = ref(false);
const modelSwitchProgress = ref('');
const modelDownloadProgress = ref<number>(0);
const isModelDownloading = ref(false);
const modelInitializationStatus = ref<'idle' | 'downloading' | 'initializing' | 'ready' | 'error'>(
'idle',
);
const modelErrorMessage = ref<string>('');
const modelErrorType = ref<'network' | 'file' | 'unknown' | ''>('');
const selectedVersion = ref<'quantized'>('quantized');
const storageStats = ref<{
indexedPages: number;
totalDocuments: number;
totalTabs: number;
indexSize: number;
isInitialized: boolean;
} | null>(null);
const isRefreshingStats = ref(false);
const isClearingData = ref(false);
const showClearConfirmation = ref(false);
const clearDataProgress = ref('');
const semanticEngineStatus = ref<'idle' | 'initializing' | 'ready' | 'error'>('idle');
const isSemanticEngineInitializing = ref(false);
const semanticEngineInitProgress = ref('');
const semanticEngineLastUpdated = ref<number | null>(null);
// Cache management
const isManagingCache = ref(false);
const cacheStats = ref<{
totalSize: number;
totalSizeMB: number;
entryCount: number;
entries: Array<{
url: string;
size: number;
sizeMB: number;
timestamp: number;
age: string;
expired: boolean;
}>;
} | null>(null);
const availableModels = computed(() => {
return Object.entries(PREDEFINED_MODELS).map(([key, value]) => ({
preset: key as ModelPreset,
...value,
}));
});
const getStatusClass = () => {
if (nativeConnectionStatus.value === 'connected') {
if (serverStatus.value.isRunning) {
return 'bg-emerald-500';
} else {
return 'bg-yellow-500';
}
} else if (nativeConnectionStatus.value === 'disconnected') {
return 'bg-red-500';
} else {
return 'bg-gray-500';
}
};
const getStatusText = () => {
if (nativeConnectionStatus.value === 'connected') {
if (serverStatus.value.isRunning) {
return getMessage('serviceRunningStatus', [(serverStatus.value.port || 'Unknown').toString()]);
} else {
return getMessage('connectedServiceNotStartedStatus');
}
} else if (nativeConnectionStatus.value === 'disconnected') {
return getMessage('serviceNotConnectedStatus');
} else {
return getMessage('detectingStatus');
}
};
const formatIndexSize = () => {
if (!storageStats.value?.indexSize) return '0 MB';
const sizeInMB = Math.round(storageStats.value.indexSize / (1024 * 1024));
return `${sizeInMB} MB`;
};
const getModelDescription = (model: any) => {
switch (model.preset) {
case 'multilingual-e5-small':
return getMessage('lightweightModelDescription');
case 'multilingual-e5-base':
return getMessage('betterThanSmallDescription');
default:
return getMessage('multilingualModelDescription');
}
};
const getPerformanceText = (performance: string) => {
switch (performance) {
case 'fast':
return getMessage('fastPerformance');
case 'balanced':
return getMessage('balancedPerformance');
case 'accurate':
return getMessage('accuratePerformance');
default:
return performance;
}
};
const getSemanticEngineStatusText = () => {
switch (semanticEngineStatus.value) {
case 'ready':
return getMessage('semanticEngineReadyStatus');
case 'initializing':
return getMessage('semanticEngineInitializingStatus');
case 'error':
return getMessage('semanticEngineInitFailedStatus');
case 'idle':
default:
return getMessage('semanticEngineNotInitStatus');
}
};
const getSemanticEngineStatusClass = () => {
switch (semanticEngineStatus.value) {
case 'ready':
return 'bg-emerald-500';
case 'initializing':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
case 'idle':
default:
return 'bg-gray-500';
}
};
const getActiveTabsCount = () => {
return storageStats.value?.totalTabs || 0;
};
const getProgressText = () => {
if (isModelDownloading.value) {
return getMessage('downloadingModelStatus', [modelDownloadProgress.value.toString()]);
} else if (isModelSwitching.value) {
return modelSwitchProgress.value || getMessage('switchingModelStatus');
}
return '';
};
const getErrorTypeText = () => {
switch (modelErrorType.value) {
case 'network':
return getMessage('networkErrorMessage');
case 'file':
return getMessage('modelCorruptedErrorMessage');
case 'unknown':
default:
return getMessage('unknownErrorMessage');
}
};
const getSemanticEngineButtonText = () => {
switch (semanticEngineStatus.value) {
case 'ready':
return getMessage('reinitializeButton');
case 'initializing':
return getMessage('initializingStatus');
case 'error':
return getMessage('reinitializeButton');
case 'idle':
default:
return getMessage('initSemanticEngineButton');
}
};
const loadCacheStats = async () => {
try {
cacheStats.value = await getCacheStats();
} catch (error) {
console.error('Failed to get cache stats:', error);
cacheStats.value = null;
}
};
const cleanupCache = async () => {
if (isManagingCache.value) return;
isManagingCache.value = true;
try {
await cleanupModelCache();
// Refresh cache stats
await loadCacheStats();
} catch (error) {
console.error('Failed to cleanup cache:', error);
} finally {
isManagingCache.value = false;
}
};
const clearAllCache = async () => {
if (isManagingCache.value) return;
isManagingCache.value = true;
try {
await clearModelCache();
// Refresh cache stats
await loadCacheStats();
} catch (error) {
console.error('Failed to clear cache:', error);
} finally {
isManagingCache.value = false;
}
};
const saveSemanticEngineState = async () => {
try {
const semanticEngineState = {
status: semanticEngineStatus.value,
lastUpdated: semanticEngineLastUpdated.value,
};
// eslint-disable-next-line no-undef
await chrome.storage.local.set({ semanticEngineState });
} catch (error) {
console.error('保存语义引擎状态失败:', error);
}
};
const initializeSemanticEngine = async () => {
if (isSemanticEngineInitializing.value) return;
const isReinitialization = semanticEngineStatus.value === 'ready';
console.log(
`🚀 User triggered semantic engine ${isReinitialization ? 'reinitialization' : 'initialization'}`,
);
isSemanticEngineInitializing.value = true;
semanticEngineStatus.value = 'initializing';
semanticEngineInitProgress.value = isReinitialization
? getMessage('semanticEngineInitializingStatus')
: getMessage('semanticEngineInitializingStatus');
semanticEngineLastUpdated.value = Date.now();
await saveSemanticEngineState();
try {
// eslint-disable-next-line no-undef
chrome.runtime
.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.INITIALIZE_SEMANTIC_ENGINE,
})
.catch((error) => {
console.error('❌ Error sending semantic engine initialization request:', error);
});
startSemanticEngineStatusPolling();
semanticEngineInitProgress.value = isReinitialization
? getMessage('processingStatus')
: getMessage('processingStatus');
} catch (error: any) {
console.error('❌ Failed to send initialization request:', error);
semanticEngineStatus.value = 'error';
semanticEngineInitProgress.value = `Failed to send initialization request: ${error?.message || 'Unknown error'}`;
await saveSemanticEngineState();
setTimeout(() => {
semanticEngineInitProgress.value = '';
}, 5000);
isSemanticEngineInitializing.value = false;
semanticEngineLastUpdated.value = Date.now();
await saveSemanticEngineState();
}
};
const checkSemanticEngineStatus = async () => {
try {
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.GET_MODEL_STATUS,
});
if (response && response.success && response.status) {
const status = response.status;
if (status.initializationStatus === 'ready') {
semanticEngineStatus.value = 'ready';
semanticEngineLastUpdated.value = Date.now();
isSemanticEngineInitializing.value = false;
semanticEngineInitProgress.value = getMessage('semanticEngineReadyStatus');
await saveSemanticEngineState();
stopSemanticEngineStatusPolling();
setTimeout(() => {
semanticEngineInitProgress.value = '';
}, 2000);
} else if (
status.initializationStatus === 'downloading' ||
status.initializationStatus === 'initializing'
) {
semanticEngineStatus.value = 'initializing';
isSemanticEngineInitializing.value = true;
semanticEngineInitProgress.value = getMessage('semanticEngineInitializingStatus');
semanticEngineLastUpdated.value = Date.now();
await saveSemanticEngineState();
} else if (status.initializationStatus === 'error') {
semanticEngineStatus.value = 'error';
semanticEngineLastUpdated.value = Date.now();
isSemanticEngineInitializing.value = false;
semanticEngineInitProgress.value = getMessage('semanticEngineInitFailedStatus');
await saveSemanticEngineState();
stopSemanticEngineStatusPolling();
setTimeout(() => {
semanticEngineInitProgress.value = '';
}, 5000);
} else {
semanticEngineStatus.value = 'idle';
isSemanticEngineInitializing.value = false;
await saveSemanticEngineState();
}
} else {
semanticEngineStatus.value = 'idle';
isSemanticEngineInitializing.value = false;
await saveSemanticEngineState();
}
} catch (error) {
console.error('Popup: Failed to check semantic engine status:', error);
semanticEngineStatus.value = 'idle';
isSemanticEngineInitializing.value = false;
await saveSemanticEngineState();
}
};
const retryModelInitialization = async () => {
if (!currentModel.value) return;
console.log('🔄 Retrying model initialization...');
modelErrorMessage.value = '';
modelErrorType.value = '';
modelInitializationStatus.value = 'downloading';
modelDownloadProgress.value = 0;
isModelDownloading.value = true;
await switchModel(currentModel.value);
};
const updatePort = async (event: Event) => {
const target = event.target as HTMLInputElement;
const newPort = Number(target.value);
nativeServerPort.value = newPort;
await savePortPreference(newPort);
};
const checkNativeConnection = async () => {
try {
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({ type: 'ping_native' });
nativeConnectionStatus.value = response?.connected ? 'connected' : 'disconnected';
} catch (error) {
console.error('检测 Native 连接状态失败:', error);
nativeConnectionStatus.value = 'disconnected';
}
};
const checkServerStatus = async () => {
try {
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS,
});
if (response?.success && response.serverStatus) {
serverStatus.value = response.serverStatus;
}
if (response?.connected !== undefined) {
nativeConnectionStatus.value = response.connected ? 'connected' : 'disconnected';
}
} catch (error) {
console.error('检测服务器状态失败:', error);
}
};
const refreshServerStatus = async () => {
try {
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS,
});
if (response?.success && response.serverStatus) {
serverStatus.value = response.serverStatus;
}
if (response?.connected !== undefined) {
nativeConnectionStatus.value = response.connected ? 'connected' : 'disconnected';
}
} catch (error) {
console.error('刷新服务器状态失败:', error);
}
};
const copyMcpConfig = async () => {
try {
await navigator.clipboard.writeText(mcpConfigJson.value);
copyButtonText.value = '✅' + getMessage('configCopiedNotification');
setTimeout(() => {
copyButtonText.value = getMessage('copyConfigButton');
}, 2000);
} catch (error) {
console.error('复制配置失败:', error);
copyButtonText.value = '❌' + getMessage('networkErrorMessage');
setTimeout(() => {
copyButtonText.value = getMessage('copyConfigButton');
}, 2000);
}
};
const testNativeConnection = async () => {
if (isConnecting.value) return;
isConnecting.value = true;
try {
if (nativeConnectionStatus.value === 'connected') {
// eslint-disable-next-line no-undef
await chrome.runtime.sendMessage({ type: 'disconnect_native' });
nativeConnectionStatus.value = 'disconnected';
} else {
console.log(`尝试连接到端口: ${nativeServerPort.value}`);
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: 'connectNative',
port: nativeServerPort.value,
});
if (response && response.success) {
nativeConnectionStatus.value = 'connected';
console.log('连接成功:', response);
await savePortPreference(nativeServerPort.value);
} else {
nativeConnectionStatus.value = 'disconnected';
console.error('连接失败:', response);
}
}
} catch (error) {
console.error('测试连接失败:', error);
nativeConnectionStatus.value = 'disconnected';
} finally {
isConnecting.value = false;
}
};
const loadModelPreference = async () => {
try {
// eslint-disable-next-line no-undef
const result = await chrome.storage.local.get([
'selectedModel',
'selectedVersion',
'modelState',
'semanticEngineState',
]);
if (result.selectedModel) {
const storedModel = result.selectedModel as string;
console.log('📋 Stored model from storage:', storedModel);
if (PREDEFINED_MODELS[storedModel as ModelPreset]) {
currentModel.value = storedModel as ModelPreset;
console.log(`✅ Loaded valid model: ${currentModel.value}`);
} else {
console.warn(
`⚠️ Stored model "${storedModel}" not found in PREDEFINED_MODELS, using default`,
);
currentModel.value = 'multilingual-e5-small';
await saveModelPreference(currentModel.value);
}
} else {
console.log('⚠️ No model found in storage, using default');
currentModel.value = 'multilingual-e5-small';
await saveModelPreference(currentModel.value);
}
selectedVersion.value = 'quantized';
console.log('✅ Using quantized version (fixed)');
await saveVersionPreference('quantized');
if (result.modelState) {
const modelState = result.modelState;
if (modelState.status === 'ready') {
modelInitializationStatus.value = 'ready';
modelDownloadProgress.value = modelState.downloadProgress || 100;
isModelDownloading.value = false;
} else {
modelInitializationStatus.value = 'idle';
modelDownloadProgress.value = 0;
isModelDownloading.value = false;
await saveModelState();
}
} else {
modelInitializationStatus.value = 'idle';
modelDownloadProgress.value = 0;
isModelDownloading.value = false;
}
if (result.semanticEngineState) {
const semanticState = result.semanticEngineState;
if (semanticState.status === 'ready') {
semanticEngineStatus.value = 'ready';
semanticEngineLastUpdated.value = semanticState.lastUpdated || Date.now();
} else if (semanticState.status === 'error') {
semanticEngineStatus.value = 'error';
semanticEngineLastUpdated.value = semanticState.lastUpdated || Date.now();
} else {
semanticEngineStatus.value = 'idle';
}
} else {
semanticEngineStatus.value = 'idle';
}
} catch (error) {
console.error('❌ 加载模型偏好失败:', error);
}
};
const saveModelPreference = async (model: ModelPreset) => {
try {
// eslint-disable-next-line no-undef
await chrome.storage.local.set({ selectedModel: model });
} catch (error) {
console.error('保存模型偏好失败:', error);
}
};
const saveVersionPreference = async (version: 'full' | 'quantized' | 'compressed') => {
try {
// eslint-disable-next-line no-undef
await chrome.storage.local.set({ selectedVersion: version });
} catch (error) {
console.error('保存版本偏好失败:', error);
}
};
const savePortPreference = async (port: number) => {
try {
// eslint-disable-next-line no-undef
await chrome.storage.local.set({ nativeServerPort: port });
console.log(`端口偏好已保存: ${port}`);
} catch (error) {
console.error('保存端口偏好失败:', error);
}
};
const loadPortPreference = async () => {
try {
// eslint-disable-next-line no-undef
const result = await chrome.storage.local.get(['nativeServerPort']);
if (result.nativeServerPort) {
nativeServerPort.value = result.nativeServerPort;
console.log(`端口偏好已加载: ${result.nativeServerPort}`);
}
} catch (error) {
console.error('加载端口偏好失败:', error);
}
};
const saveModelState = async () => {
try {
const modelState = {
status: modelInitializationStatus.value,
downloadProgress: modelDownloadProgress.value,
isDownloading: isModelDownloading.value,
lastUpdated: Date.now(),
};
// eslint-disable-next-line no-undef
await chrome.storage.local.set({ modelState });
} catch (error) {
console.error('保存模型状态失败:', error);
}
};
let statusMonitoringInterval: ReturnType<typeof setInterval> | null = null;
let semanticEngineStatusPollingInterval: ReturnType<typeof setInterval> | null = null;
const startModelStatusMonitoring = () => {
if (statusMonitoringInterval) {
clearInterval(statusMonitoringInterval);
}
statusMonitoringInterval = setInterval(async () => {
try {
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: 'get_model_status',
});
if (response && response.success) {
const status = response.status;
modelInitializationStatus.value = status.initializationStatus || 'idle';
modelDownloadProgress.value = status.downloadProgress || 0;
isModelDownloading.value = status.isDownloading || false;
if (status.initializationStatus === 'error') {
modelErrorMessage.value = status.errorMessage || getMessage('modelFailedStatus');
modelErrorType.value = status.errorType || 'unknown';
} else {
modelErrorMessage.value = '';
modelErrorType.value = '';
}
await saveModelState();
if (status.initializationStatus === 'ready' || status.initializationStatus === 'error') {
stopModelStatusMonitoring();
}
}
} catch (error) {
console.error('获取模型状态失败:', error);
}
}, 1000);
};
const stopModelStatusMonitoring = () => {
if (statusMonitoringInterval) {
clearInterval(statusMonitoringInterval);
statusMonitoringInterval = null;
}
};
const startSemanticEngineStatusPolling = () => {
if (semanticEngineStatusPollingInterval) {
clearInterval(semanticEngineStatusPollingInterval);
}
semanticEngineStatusPollingInterval = setInterval(async () => {
try {
await checkSemanticEngineStatus();
} catch (error) {
console.error('Semantic engine status polling failed:', error);
}
}, 2000);
};
const stopSemanticEngineStatusPolling = () => {
if (semanticEngineStatusPollingInterval) {
clearInterval(semanticEngineStatusPollingInterval);
semanticEngineStatusPollingInterval = null;
}
};
const refreshStorageStats = async () => {
if (isRefreshingStats.value) return;
isRefreshingStats.value = true;
try {
console.log('🔄 Refreshing storage statistics...');
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: 'get_storage_stats',
});
if (response && response.success) {
storageStats.value = {
indexedPages: response.stats.indexedPages || 0,
totalDocuments: response.stats.totalDocuments || 0,
totalTabs: response.stats.totalTabs || 0,
indexSize: response.stats.indexSize || 0,
isInitialized: response.stats.isInitialized || false,
};
console.log('✅ Storage stats refreshed:', storageStats.value);
} else {
console.error('❌ Failed to get storage stats:', response?.error);
storageStats.value = {
indexedPages: 0,
totalDocuments: 0,
totalTabs: 0,
indexSize: 0,
isInitialized: false,
};
}
} catch (error) {
console.error('❌ Error refreshing storage stats:', error);
storageStats.value = {
indexedPages: 0,
totalDocuments: 0,
totalTabs: 0,
indexSize: 0,
isInitialized: false,
};
} finally {
isRefreshingStats.value = false;
}
};
const hideClearDataConfirmation = () => {
showClearConfirmation.value = false;
};
const confirmClearAllData = async () => {
if (isClearingData.value) return;
isClearingData.value = true;
clearDataProgress.value = getMessage('clearingStatus');
try {
console.log('🗑️ Starting to clear all data...');
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: 'clear_all_data',
});
if (response && response.success) {
clearDataProgress.value = getMessage('dataClearedNotification');
console.log('✅ All data cleared successfully');
await refreshStorageStats();
setTimeout(() => {
clearDataProgress.value = '';
hideClearDataConfirmation();
}, 2000);
} else {
throw new Error(response?.error || 'Failed to clear data');
}
} catch (error: any) {
console.error('❌ Failed to clear all data:', error);
clearDataProgress.value = `Failed to clear data: ${error?.message || 'Unknown error'}`;
setTimeout(() => {
clearDataProgress.value = '';
}, 5000);
} finally {
isClearingData.value = false;
}
};
const switchModel = async (newModel: ModelPreset) => {
console.log(`🔄 switchModel called with newModel: ${newModel}`);
if (isModelSwitching.value) {
console.log('⏸️ Model switch already in progress, skipping');
return;
}
const isSameModel = newModel === currentModel.value;
const currentModelInfo = currentModel.value
? getModelInfo(currentModel.value)
: getModelInfo('multilingual-e5-small');
const newModelInfo = getModelInfo(newModel);
const isDifferentDimension = currentModelInfo.dimension !== newModelInfo.dimension;
console.log(`📊 Switch analysis:`);
console.log(` - Same model: ${isSameModel} (${currentModel.value} -> ${newModel})`);
console.log(
` - Current dimension: ${currentModelInfo.dimension}, New dimension: ${newModelInfo.dimension}`,
);
console.log(` - Different dimension: ${isDifferentDimension}`);
if (isSameModel && !isDifferentDimension) {
console.log('✅ Same model and dimension - no need to switch');
return;
}
const switchReasons = [];
if (!isSameModel) switchReasons.push('different model');
if (isDifferentDimension) switchReasons.push('different dimension');
console.log(`🚀 Switching model due to: ${switchReasons.join(', ')}`);
console.log(
`📋 Model: ${currentModel.value} (${currentModelInfo.dimension}D) -> ${newModel} (${newModelInfo.dimension}D)`,
);
isModelSwitching.value = true;
modelSwitchProgress.value = getMessage('switchingModelStatus');
modelInitializationStatus.value = 'downloading';
modelDownloadProgress.value = 0;
isModelDownloading.value = true;
try {
await saveModelPreference(newModel);
await saveVersionPreference('quantized');
await saveModelState();
modelSwitchProgress.value = getMessage('semanticEngineInitializingStatus');
startModelStatusMonitoring();
// eslint-disable-next-line no-undef
const response = await chrome.runtime.sendMessage({
type: 'switch_semantic_model',
modelPreset: newModel,
modelVersion: 'quantized',
modelDimension: newModelInfo.dimension,
previousDimension: currentModelInfo.dimension,
});
if (response && response.success) {
currentModel.value = newModel;
modelSwitchProgress.value = getMessage('successNotification');
console.log(
'模型切换成功:',
newModel,
'version: quantized',
'dimension:',
newModelInfo.dimension,
);
modelInitializationStatus.value = 'ready';
isModelDownloading.value = false;
await saveModelState();
setTimeout(() => {
modelSwitchProgress.value = '';
}, 2000);
} else {
throw new Error(response?.error || 'Model switch failed');
}
} catch (error: any) {
console.error('模型切换失败:', error);
modelSwitchProgress.value = `Model switch failed: ${error?.message || 'Unknown error'}`;
modelInitializationStatus.value = 'error';
isModelDownloading.value = false;
const errorMessage = error?.message || '未知错误';
if (
errorMessage.includes('network') ||
errorMessage.includes('fetch') ||
errorMessage.includes('timeout')
) {
modelErrorType.value = 'network';
modelErrorMessage.value = getMessage('networkErrorMessage');
} else if (
errorMessage.includes('corrupt') ||
errorMessage.includes('invalid') ||
errorMessage.includes('format')
) {
modelErrorType.value = 'file';
modelErrorMessage.value = getMessage('modelCorruptedErrorMessage');
} else {
modelErrorType.value = 'unknown';
modelErrorMessage.value = errorMessage;
}
await saveModelState();
setTimeout(() => {
modelSwitchProgress.value = '';
}, 8000);
} finally {
isModelSwitching.value = false;
}
};
const setupServerStatusListener = () => {
// eslint-disable-next-line no-undef
chrome.runtime.onMessage.addListener((message) => {
if (message.type === BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED && message.payload) {
serverStatus.value = message.payload;
console.log('Server status updated:', message.payload);
}
});
};
onMounted(async () => {
await loadPortPreference();
await loadModelPreference();
await checkNativeConnection();
await checkServerStatus();
await refreshStorageStats();
await loadCacheStats();
await checkSemanticEngineStatus();
setupServerStatusListener();
});
onUnmounted(() => {
stopModelStatusMonitoring();
stopSemanticEngineStatusPolling();
});
</script>
<style scoped>
.popup-container {
background: #f1f5f9;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
flex-shrink: 0;
padding-left: 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.settings-button {
padding: 8px;
border-radius: 50%;
color: #64748b;
background: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.settings-button:hover {
background: #e2e8f0;
color: #1e293b;
}
.content {
flex-grow: 1;
padding: 8px 24px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.content::-webkit-scrollbar {
display: none;
}
.status-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
}
.status-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin-bottom: 8px;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
height: 8px;
width: 8px;
border-radius: 50%;
}
.status-dot.bg-emerald-500 {
background-color: #10b981;
}
.status-dot.bg-red-500 {
background-color: #ef4444;
}
.status-dot.bg-yellow-500 {
background-color: #eab308;
}
.status-dot.bg-gray-500 {
background-color: #6b7280;
}
.status-text {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.model-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin-bottom: 4px;
}
.model-name {
font-weight: 600;
color: #7c3aed;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stats-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 16px;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.stats-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stats-icon {
padding: 8px;
border-radius: 8px;
}
.stats-icon.violet {
background: #ede9fe;
color: #7c3aed;
}
.stats-icon.teal {
background: #ccfbf1;
color: #0d9488;
}
.stats-icon.blue {
background: #dbeafe;
color: #2563eb;
}
.stats-icon.green {
background: #dcfce7;
color: #16a34a;
}
.stats-value {
font-size: 30px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.section {
margin-bottom: 24px;
}
.secondary-button {
background: #f1f5f9;
color: #475569;
border: 1px solid #cbd5e1;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
}
.secondary-button:hover:not(:disabled) {
background: #e2e8f0;
border-color: #94a3b8;
}
.secondary-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary-button {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.primary-button:hover {
background: #2563eb;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.current-model-card {
background: linear-gradient(135deg, #faf5ff, #f3e8ff);
border: 1px solid #e9d5ff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.current-model-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.current-model-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin: 0;
}
.current-model-badge {
background: #8b5cf6;
color: white;
font-size: 12px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
}
.current-model-name {
font-size: 16px;
font-weight: 700;
color: #7c3aed;
margin: 0;
}
.model-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-card {
background: white;
border-radius: 12px;
padding: 16px;
cursor: pointer;
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
}
.model-card:hover {
border-color: #8b5cf6;
}
.model-card.selected {
border: 2px solid #8b5cf6;
background: #faf5ff;
}
.model-card.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.model-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.model-info {
flex: 1;
}
.model-name {
font-weight: 600;
color: #1e293b;
margin: 0 0 4px 0;
}
.model-name.selected-text {
color: #7c3aed;
}
.model-description {
font-size: 14px;
color: #64748b;
margin: 0;
}
.check-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
background: #8b5cf6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.model-tags {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
}
.model-tag {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
}
.model-tag.performance {
background: #d1fae5;
color: #065f46;
}
.model-tag.size {
background: #ddd6fe;
color: #5b21b6;
}
.model-tag.dimension {
background: #e5e7eb;
color: #4b5563;
}
.config-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.semantic-engine-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.semantic-engine-status {
display: flex;
flex-direction: column;
gap: 8px;
}
.semantic-engine-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #8b5cf6;
color: white;
font-weight: 600;
padding: 12px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.semantic-engine-button:hover:not(:disabled) {
background: #7c3aed;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.semantic-engine-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.refresh-status-button {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 14px;
color: #64748b;
transition: all 0.2s ease;
}
.refresh-status-button:hover {
background: #f1f5f9;
color: #374151;
}
.status-timestamp {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.mcp-config-section {
border-top: 1px solid #f1f5f9;
}
.mcp-config-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.mcp-config-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
margin: 0;
}
.copy-config-button {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 14px;
color: #64748b;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
}
.copy-config-button:hover {
background: #f1f5f9;
color: #374151;
}
.mcp-config-content {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
overflow-x: auto;
}
.mcp-config-json {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.4;
color: #374151;
margin: 0;
white-space: pre;
overflow-x: auto;
}
.port-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.port-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.port-input {
display: block;
width: 100%;
border-radius: 8px;
border: 1px solid #d1d5db;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
padding: 12px;
font-size: 14px;
background: #f8fafc;
}
.port-input:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.connect-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #8b5cf6;
color: white;
font-weight: 600;
padding: 12px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.connect-button:hover:not(:disabled) {
background: #7c3aed;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.connect-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-card {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 16px;
}
.error-content {
flex: 1;
display: flex;
align-items: flex-start;
gap: 12px;
}
.error-icon {
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
.error-details {
flex: 1;
}
.error-title {
font-size: 14px;
font-weight: 600;
color: #dc2626;
margin: 0 0 4px 0;
}
.error-message {
font-size: 14px;
color: #991b1b;
margin: 0 0 8px 0;
font-weight: 500;
}
.error-suggestion {
font-size: 13px;
color: #7f1d1d;
margin: 0;
line-height: 1.4;
}
.retry-button {
display: flex;
align-items: center;
gap: 6px;
background: #dc2626;
color: white;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
flex-shrink: 0;
}
.retry-button:hover:not(:disabled) {
background: #b91c1c;
}
.retry-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.danger-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: white;
border: 1px solid #d1d5db;
color: #374151;
font-weight: 600;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 16px;
}
.danger-button:hover:not(:disabled) {
border-color: #ef4444;
color: #dc2626;
}
.danger-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.icon-small {
width: 14px;
height: 14px;
}
.icon-default {
width: 20px;
height: 20px;
}
.icon-medium {
width: 24px;
height: 24px;
}
.footer {
padding: 16px;
margin-top: auto;
}
.footer-text {
text-align: center;
font-size: 12px;
color: #94a3b8;
margin: 0;
}
@media (max-width: 320px) {
.popup-container {
width: 100%;
height: 100vh;
border-radius: 0;
}
.header {
padding: 24px 20px 12px;
}
.content {
padding: 8px 20px;
}
.stats-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.config-card {
padding: 16px;
gap: 12px;
}
.current-model-card {
padding: 12px;
margin-bottom: 12px;
}
.stats-card {
padding: 12px;
}
.stats-value {
font-size: 24px;
}
}
</style>