diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index e705bb4..60c9785 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,4 +1,7 @@ releases: + - version: "0.8.5" + features: + - "add mobile version for pro user, fix update error" - version: "0.8.4" features: - "test mobile version" diff --git a/manifest.json b/manifest.json index 0de8a17..2232fd2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ { "id": "infio-copilot", "name": "Infio Copilot", - "version": "0.8.4", + "version": "0.8.5", "minAppVersion": "0.15.0", "description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes", "author": "Felix.D", "authorUrl": "https://github.com/infiolab", - "isDesktopOnly": false + "isDesktopOnly": true } diff --git a/package.json b/package.json index b0e663f..4955375 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-infio-copilot", - "version": "0.8.4", + "version": "0.8.5", "description": "A Cursor-inspired AI assistant that offers smart autocomplete and interactive chat with your selected notes", "main": "main.js", "scripts": { diff --git a/src/hooks/use-infio.ts b/src/hooks/use-infio.ts index d3c34d8..a3f30a9 100644 --- a/src/hooks/use-infio.ts +++ b/src/hooks/use-infio.ts @@ -1,29 +1,9 @@ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ import JSZip from "jszip"; -import { machineId } from 'node-machine-id'; -import { Notice, Platform, Plugin, requestUrl } from "obsidian"; +import { Notice, Plugin, requestUrl } from "obsidian"; import { INFIO_BASE_URL } from "../constants"; - -function getOperatingSystem(): string { - if (Platform.isWin) { - return 'windows'; - } - if (Platform.isMacOS) { - return 'macos'; - } - if (Platform.isLinux) { - return 'linux'; - } - if (Platform.isAndroidApp) { - return 'android'; - } - if (Platform.isIosApp) { - return 'ios'; - } - // 如果所有已知的平台都不是,则返回未知 - return 'unknown'; -} +import { getDeviceId, getOperatingSystem } from "../utils/device-id"; // API响应类型定义 export type UserPlanResponse = { @@ -76,8 +56,8 @@ export const checkGeneral = async ( if (!apiKey) { throw new Error('API密钥不能为空'); } - const deviceId = await machineId(); - const deviceName = getOperatingSystem(); + const deviceId = await getDeviceId(); + const deviceName = getOperatingSystem(); if (!deviceId || !deviceName) { throw new Error('设备ID和设备名称不能为空'); } @@ -336,12 +316,12 @@ export const upgradeToProVersion = async ( console.log(`重载插件: ${plugin.manifest.id}`); try { // 禁用插件 - // @ts-ignore + // @ts-expect-error obsidian typings do not expose this internal API await plugin.app.plugins.disablePlugin(plugin.manifest.id); console.log(`插件已禁用: ${plugin.manifest.id}`); // 启用插件 - // @ts-ignore + // @ts-expect-error obsidian typings do not expose this internal API await plugin.app.plugins.enablePlugin(plugin.manifest.id); console.log(`插件已重新启用: ${plugin.manifest.id}`); diff --git a/src/main.desktop.ts b/src/main.desktop.ts new file mode 100644 index 0000000..e2ebc88 --- /dev/null +++ b/src/main.desktop.ts @@ -0,0 +1,568 @@ +// @ts-nocheck +import { EditorView } from '@codemirror/view' +// import { PGlite } from '@electric-sql/pglite' +import { Editor, MarkdownView, Modal, Notice, Plugin, TFile } from 'obsidian' + +import { ApplyView } from './ApplyView' +import { ChatView } from './ChatView' +import { ChatProps } from './components/chat-view/ChatView' +import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE, JSON_VIEW_TYPE, PREVIEW_VIEW_TYPE } from './constants' +import { getDiffStrategy } from "./core/diff/DiffStrategy" +import { InlineEdit } from './core/edit/inline-edit-processor' +import { McpHub } from './core/mcp/McpHub' +import { RAGEngine } from './core/rag/rag-engine' +import { TransEngine } from './core/transformations/trans-engine' +import { DBManager } from './database/database-manager' +import { migrateToJsonDatabase } from './database/json/migrateToJsonDatabase' +import { EmbeddingManager } from './embedworker/EmbeddingManager' +import EventListener from "./event-listener" +import JsonView from './JsonFileView' +import { t } from './lang/helpers' +import { PreviewView } from './PreviewView' +import CompletionKeyWatcher from "./render-plugin/completion-key-watcher" +import DocumentChangesListener, { + DocumentChanges, + getPrefix, getSuffix, + hasMultipleCursors, + hasSelection +} from "./render-plugin/document-changes-listener" +import RenderSuggestionPlugin from "./render-plugin/render-surgestion-plugin" +import { InlineSuggestionState } from "./render-plugin/states" +import { InfioSettingTab } from './settings/SettingTab' +import StatusBar from "./status-bar" +import { + InfioSettings, + parseInfioSettings, +} from './types/settings' +import { createDataviewManager, DataviewManager } from './utils/dataview' +import { getMentionableBlockData } from './utils/obsidian' +import './utils/path' +import { onEnt } from './utils/web-search' + +type DesktopAugmented = Plugin & { + metadataCacheUnloadFn: (() => void) | null + activeLeafChangeUnloadFn: (() => void) | null + dbManagerInitPromise: Promise | null + ragEngineInitPromise: Promise | null + transEngineInitPromise: Promise | null + mcpHubInitPromise: Promise | null + settings: InfioSettings + settingTab: InfioSettingTab + settingsListeners: ((newSettings: InfioSettings) => void)[] + initChatProps?: ChatProps + dbManager: DBManager | null + mcpHub: McpHub | null + ragEngine: RAGEngine | null + transEngine: TransEngine | null + embeddingManager: EmbeddingManager | null + inlineEdit: InlineEdit | null + diffStrategy?: DiffStrategy + dataviewManager: DataviewManager | null + + // methods (attached below) + loadSettings: () => Promise + setSettings: (newSettings: InfioSettings) => Promise + addSettingsListener: (listener: (newSettings: InfioSettings) => void) => () => void + openChatView: (openNewChat?: boolean) => Promise + activateChatView: (chatProps?: ChatProps, openNewChat?: boolean) => Promise + addSelectionToChat: (editor: Editor, view: MarkdownView) => Promise + getDbManager: () => Promise + getMcpHub: () => Promise + getRAGEngine: () => Promise + getTransEngine: () => Promise + getEmbeddingManager: () => EmbeddingManager | null + migrateToJsonStorage: () => Promise + reloadChatView: () => Promise +} + +export async function loadDesktop(base: Plugin) { + const plugin = base as DesktopAugmented + // initialize fields + plugin.metadataCacheUnloadFn = null + plugin.activeLeafChangeUnloadFn = null + plugin.dbManagerInitPromise = null + plugin.ragEngineInitPromise = null + plugin.transEngineInitPromise = null + plugin.mcpHubInitPromise = null + plugin.initChatProps = undefined + plugin.dbManager = null + plugin.mcpHub = null + plugin.ragEngine = null + plugin.transEngine = null + plugin.embeddingManager = null + plugin.inlineEdit = null + plugin.diffStrategy = undefined + plugin.dataviewManager = null + plugin.settingsListeners = [] + + // attach methods migrated from original class + plugin.loadSettings = async function () { + this.settings = parseInfioSettings(await this.loadData()) + await this.saveData(this.settings) + } + plugin.setSettings = async function (newSettings: InfioSettings) { + this.settings = newSettings + await this.saveData(newSettings) + this.ragEngine?.setSettings(newSettings) + this.transEngine?.setSettings(newSettings) + this.settingsListeners.forEach((listener) => listener(newSettings)) + } + plugin.addSettingsListener = function (listener: (ns: InfioSettings) => void) { + this.settingsListeners.push(listener) + return () => { + this.settingsListeners = this.settingsListeners.filter((l) => l !== listener) + } + } + plugin.openChatView = async function (openNewChat = false) { + const view = this.app.workspace.getActiveViewOfType(MarkdownView) + const editor = view?.editor + if (!view || !editor) { + await this.activateChatView(undefined, openNewChat) + return + } + const selectedBlockData = await getMentionableBlockData(editor, view) + await this.activateChatView({ selectedBlock: selectedBlockData ?? undefined }, openNewChat) + } + plugin.activateChatView = async function (chatProps?: ChatProps, openNewChat = false) { + this.initChatProps = chatProps + const leaf = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0] + await (leaf ?? this.app.workspace.getRightLeaf(false))?.setViewState({ type: CHAT_VIEW_TYPE, active: true }) + if (openNewChat && leaf && leaf.view instanceof ChatView) { + leaf.view.openNewChat(chatProps?.selectedBlock) + } + this.app.workspace.revealLeaf(this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0]) + } + plugin.addSelectionToChat = async function (editor: Editor, view: MarkdownView) { + const data = await getMentionableBlockData(editor, view) + if (!data) return + const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE) + if (leaves.length === 0 || !(leaves[0].view instanceof ChatView)) { + await this.activateChatView({ selectedBlock: data }) + return + } + await this.app.workspace.revealLeaf(leaves[0]) + const chatView = leaves[0].view + chatView.addSelectionToChat(data) + chatView.focusMessage() + } + plugin.getDbManager = async function (): Promise { + if (this.dbManager) return this.dbManager + if (!this.dbManagerInitPromise) { + this.dbManagerInitPromise = (async () => { + this.dbManager = await DBManager.create(this.app, this.settings.ragOptions.filesystem) + return this.dbManager + })() + } + return this.dbManagerInitPromise + } + plugin.getMcpHub = async function (): Promise { + if (!this.settings.mcpEnabled) return null + if (this.mcpHub) return this.mcpHub + if (!this.mcpHubInitPromise) { + this.mcpHubInitPromise = (async () => { + this.mcpHub = new McpHub(this.app, this as unknown as Plugin) + await this.mcpHub.onload() + return this.mcpHub + })() + } + return this.mcpHubInitPromise + } + plugin.getRAGEngine = async function (): Promise { + if (this.ragEngine) return this.ragEngine + if (!this.ragEngineInitPromise) { + this.ragEngineInitPromise = (async () => { + const dbManager = await this.getDbManager() + this.ragEngine = new RAGEngine(this.app, this.settings, dbManager, this.embeddingManager) + return this.ragEngine + })() + } + return this.ragEngineInitPromise + } + plugin.getTransEngine = async function (): Promise { + if (this.transEngine) return this.transEngine + if (!this.transEngineInitPromise) { + this.transEngineInitPromise = (async () => { + const dbManager = await this.getDbManager() + this.transEngine = new TransEngine(this.app, this.settings, dbManager, this.embeddingManager) + return this.transEngine + })() + } + return this.transEngineInitPromise + } + plugin.getEmbeddingManager = function (): EmbeddingManager | null { + return this.embeddingManager + } + plugin.migrateToJsonStorage = async function () { + try { + const dbManager = await this.getDbManager() + await migrateToJsonDatabase(this.app, dbManager, async () => { + await this.reloadChatView() + console.log('Migration to JSON storage completed successfully') + }) + } catch (error) { + console.error('Failed to migrate to JSON storage:', error) + new Notice(t('notifications.migrationFailed')) + } + } + plugin.reloadChatView = async function () { + const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE) + if (leaves.length === 0 || !(leaves[0].view instanceof ChatView)) return + new Notice(t('notifications.reloadingInfio'), 1000) + leaves[0].detach() + await this.activateChatView() + } + + // ==== Original onload body starts here (adapted) ==== + await plugin.loadSettings() + + setTimeout(() => { + void plugin.migrateToJsonStorage().then(() => { }) + void onEnt('loaded') + }, 100) + + plugin.settingTab = new InfioSettingTab(plugin.app, plugin as unknown as any) + plugin.addSettingTab(plugin.settingTab) + + plugin.dataviewManager = createDataviewManager(plugin.app) + + plugin.embeddingManager = new EmbeddingManager() + console.log('EmbeddingManager initialized') + + plugin.addRibbonIcon('wand-sparkles', t('main.openInfioCopilot'), () => plugin.openChatView()) + + plugin.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, plugin as unknown as any)) + plugin.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf)) + plugin.registerView(PREVIEW_VIEW_TYPE, (leaf) => new PreviewView(leaf)) + plugin.registerView(JSON_VIEW_TYPE, (leaf) => new JsonView(leaf, plugin as unknown as any)) + + plugin.inlineEdit = new InlineEdit(plugin as unknown as any, plugin.settings); + plugin.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => { + plugin.inlineEdit?.Processor(source, el, ctx); + }); + + const statusBar = StatusBar.fromApp(plugin as unknown as any); + const eventListener = EventListener.fromSettings( + plugin.settings, + statusBar, + plugin.app + ); + + plugin.diffStrategy = getDiffStrategy( + plugin.settings.chatModelId || "", + plugin.app, + plugin.settings.fuzzyMatchThreshold, + plugin.settings.experimentalDiffStrategy, + plugin.settings.multiSearchReplaceDiffStrategy, + ) + + plugin.addSettingsListener((newSettings) => { + plugin.inlineEdit = new InlineEdit(plugin as unknown as any, newSettings); + eventListener.handleSettingChanged(newSettings) + plugin.diffStrategy = getDiffStrategy( + plugin.settings.chatModelId || "", + plugin.app, + plugin.settings.fuzzyMatchThreshold, + plugin.settings.experimentalDiffStrategy, + plugin.settings.multiSearchReplaceDiffStrategy, + ) + if (plugin.settings.mcpEnabled && !plugin.mcpHub) { + void plugin.getMcpHub() + } else if (!plugin.settings.mcpEnabled && plugin.mcpHub) { + plugin.mcpHub.dispose() + plugin.mcpHub = null + plugin.mcpHubInitPromise = null + } + }); + + plugin.registerEditorExtension([ + InlineSuggestionState, + CompletionKeyWatcher( + eventListener.handleAcceptKeyPressed.bind(eventListener) as () => boolean, + eventListener.handlePartialAcceptKeyPressed.bind(eventListener) as () => boolean, + eventListener.handleCancelKeyPressed.bind(eventListener) as () => boolean, + ), + DocumentChangesListener( + eventListener.handleDocumentChange.bind(eventListener) as (documentChange: DocumentChanges) => Promise + ), + RenderSuggestionPlugin(), + ]); + + plugin.app.workspace.onLayoutReady(() => { + const view = plugin.app.workspace.getActiveViewOfType(MarkdownView); + if (view) { + // @ts-expect-error, not typed + const editorView = view.editor.cm as EditorView; + eventListener.onViewUpdate(editorView); + } + }); + + plugin.registerEvent( + plugin.app.workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof MarkdownView) { + // @ts-expect-error, not typed + const editorView = leaf.view.editor.cm as EditorView; + eventListener.onViewUpdate(editorView); + if (leaf.view.file) { + eventListener.handleFileChange(leaf.view.file); + } + } + }) + ); + + plugin.registerEvent( + plugin.app.metadataCache.on("changed", (file: TFile) => { + if (file) { + eventListener.handleFileChange(file); + // is not worth it to update the file index on every file change + // plugin.ragEngine?.updateFileIndex(file); + } + }) + ); + + plugin.registerEvent( + plugin.app.metadataCache.on("deleted", (file: TFile) => { + if (file) { + plugin.ragEngine?.deleteFileIndex(file); + } + }) + ); + + plugin.addCommand({ + id: 'open-new-chat', + name: t('main.openNewChat'), + callback: () => plugin.openChatView(true), + }) + + plugin.addCommand({ + id: 'add-selection-to-chat', + name: t('main.addSelectionToChat'), + editorCallback: (editor: Editor, view: MarkdownView) => { + plugin.addSelectionToChat(editor, view) + }, + }) + + plugin.addCommand({ + id: 'rebuild-vault-index', + name: t('main.rebuildVaultIndex'), + callback: async () => { + const notice = new Notice(t('notifications.rebuildingIndex'), 0) + try { + const ragEngine = await plugin.getRAGEngine() + await ragEngine.updateVaultIndex( + { reindexAll: true }, + (queryProgress) => { + if (queryProgress.type === 'indexing') { + const { completedChunks, totalChunks } = + queryProgress.indexProgress + notice.setMessage( + t('notifications.indexingChunks', { completedChunks, totalChunks }), + ) + } + }, + ) + notice.setMessage(t('notifications.rebuildComplete')) + } catch (error) { + console.error(error) + notice.setMessage(t('notifications.rebuildFailed')) + } finally { + setTimeout(() => { notice.hide() }, 1000) + } + }, + }) + + plugin.addCommand({ + id: 'update-vault-index', + name: t('main.updateVaultIndex'), + callback: async () => { + const notice = new Notice(t('notifications.updatingIndex'), 0) + try { + const ragEngine = await plugin.getRAGEngine() + await ragEngine.updateVaultIndex( + { reindexAll: false }, + (queryProgress) => { + if (queryProgress.type === 'indexing') { + const { completedChunks, totalChunks } = + queryProgress.indexProgress + notice.setMessage( + t('notifications.indexingChunks', { completedChunks, totalChunks }), + ) + } + }, + ) + notice.setMessage(t('notifications.updateComplete')) + } catch (error) { + console.error(error) + notice.setMessage(t('notifications.updateFailed')) + } finally { + setTimeout(() => { notice.hide() }, 1000) + } + }, + }) + + plugin.addCommand({ + id: 'autocomplete-accept', + name: t('main.autocompleteAccept'), + editorCheckCallback: ( + checking: boolean, + editor: Editor, + view: MarkdownView + ) => { + if (checking) { + return ( + eventListener.isSuggesting() + ); + } + eventListener.handleAcceptCommand(); + return true; + }, + }) + + plugin.addCommand({ + id: 'autocomplete-predict', + name: t('main.autocompletePredict'), + editorCheckCallback: ( + checking: boolean, + editor: Editor, + view: MarkdownView + ) => { + // @ts-expect-error, not typed + const editorView = editor.cm as EditorView; + const state = editorView.state; + if (checking) { + return eventListener.isIdle() && !hasMultipleCursors(state) && !hasSelection(state); + } + const prefix = getPrefix(state) + const suffix = getSuffix(state) + eventListener.handlePredictCommand(prefix, suffix); + return true; + }, + }); + + plugin.addCommand({ + id: "autocomplete-toggle", + name: t('main.autocompleteToggle'), + callback: () => { + const newValue = !plugin.settings.autocompleteEnabled; + plugin.setSettings({ + ...plugin.settings, + autocompleteEnabled: newValue, + }) + }, + }); + + plugin.addCommand({ + id: "autocomplete-enable", + name: t('main.autocompleteEnable'), + checkCallback: (checking) => { + if (checking) { + return !plugin.settings.autocompleteEnabled; + } + plugin.setSettings({ + ...plugin.settings, + autocompleteEnabled: true, + }) + return true; + }, + }); + + plugin.addCommand({ + id: "autocomplete-disable", + name: t('main.autocompleteDisable'), + checkCallback: (checking) => { + if (checking) { + return plugin.settings.autocompleteEnabled; + } + plugin.setSettings({ + ...plugin.settings, + autocompleteEnabled: false, + }) + return true; + }, + }); + + plugin.addCommand({ + id: "ai-inline-edit", + name: t('main.inlineEditCommand'), + editorCallback: (editor: Editor) => { + const selection = editor.getSelection(); + if (!selection) { + new Notice(t('notifications.selectTextFirst')); + return; + } + const from = editor.getCursor("from"); + const insertPos = { line: from.line, ch: 0 }; + const customBlock = "```infioedit\n```\n"; + editor.replaceRange(customBlock, insertPos); + }, + }); + + plugin.addCommand({ + id: 'test-dataview-simple', + name: '测试 Dataview(简单查询)', + callback: async () => { + console.log('开始测试 Dataview...'); + if (!plugin.dataviewManager) { new Notice('DataviewManager 未初始化'); return; } + if (!plugin.dataviewManager.isDataviewAvailable()) { + new Notice('Dataview 插件未安装或未启用'); + console.log('Dataview API 不可用'); + return; + } + console.log('Dataview API 可用,执行简单查询...'); + try { + const result = await plugin.dataviewManager.executeQuery('LIST FROM ""'); + if (result.success) { + new Notice('Dataview 查询成功!结果已在控制台输出'); + } else { + new Notice(`查询失败: ${result.error}`); + console.error('查询错误:', result.error); + } + } catch (error) { + console.error('执行测试查询失败:', error); + new Notice('执行测试查询时发生错误'); + } + }, + }); + + plugin.addCommand({ + id: 'test-local-embed', + name: '测试本地嵌入模型', + callback: async () => { + try { + if (!plugin.embeddingManager) { new Notice('EmbeddingManager 未初始化', 5000); return; } + await plugin.embeddingManager.loadModel("Xenova/all-MiniLM-L6-v2", true); + const testText = "hello world"; + const result = await plugin.embeddingManager.embed(testText); + const resultMessage = `\n\t嵌入测试完成!\n\t文本: "${testText}"\n\tToken 数量: ${result.tokens}\n\t向量维度: ${result.vec.length}\n\t向量前4个值: [${result.vec.slice(0, 4).map(v => v.toFixed(4)).join(', ')}...]\n\t\t\t\t\t\t`.trim(); + console.log('本地嵌入测试结果:', result); + const modal = new Modal(plugin.app); + modal.titleEl.setText('本地嵌入测试结果'); + modal.contentEl.createEl('pre', { text: resultMessage }); + modal.open(); + } catch (error) { + console.error('嵌入测试失败:', error); + new Notice(`嵌入测试失败: ${error.message}`, 5000); + } + }, + }); +} + +export function unloadDesktop(base: Plugin) { + const plugin = base as DesktopAugmented + plugin.dbManagerInitPromise = null + plugin.ragEngineInitPromise = null + plugin.transEngineInitPromise = null + plugin.mcpHubInitPromise = null + plugin.ragEngine?.cleanup() + plugin.ragEngine = null + plugin.transEngine?.cleanup() + plugin.transEngine = null + plugin.dbManager?.cleanup() + plugin.dbManager = null + plugin.mcpHub?.dispose() + plugin.mcpHub = null + plugin.embeddingManager?.terminate() + plugin.embeddingManager = null + plugin.dataviewManager = null +} + + diff --git a/src/main.mobile.ts b/src/main.mobile.ts new file mode 100644 index 0000000..e4d121f --- /dev/null +++ b/src/main.mobile.ts @@ -0,0 +1,491 @@ +// @ts-check + +import JSZip from "jszip"; +import { App, Notice, Platform, Plugin, PluginSettingTab, Setting, requestUrl } from 'obsidian'; + +import { ApiKeyModal } from './components/modals/ApiKeyModal'; +import { ProUpgradeModal } from './components/modals/ProUpgradeModal'; +// import { checkGeneral, fetchUserPlan, upgradeToProVersion } from './hooks/use-infio'; +import { InfioSettings, parseInfioSettings } from './types/settings-mobile'; +import { getDeviceId, getOperatingSystem } from './utils/device-id'; + +INFIO_BASE_URL = 'https://api.infio.app' + +// API响应类型定义 +export type CheckGeneralResponse = { + success: boolean; + message: string; + dl_zip?: string; +}; + +export type CheckGeneralParams = { + device_id: string; + device_name: string; +}; + +/** + * 检查设备一般状态 + * @param apiKey API密钥 + * @param deviceId 设备ID + * @param deviceName 设备名称 + * @returns Promise + */ +export const checkGeneral = async ( + apiKey: string +): Promise => { + try { + if (!apiKey) { + throw new Error('API密钥不能为空'); + } + const deviceId = await getDeviceId(); + const deviceName = getOperatingSystem(); + if (!deviceId || !deviceName) { + throw new Error('设备ID和设备名称不能为空'); + } + + const response = await requestUrl({ + url: `${INFIO_BASE_URL}/subscription/check_general`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + device_id: deviceId, + device_name: deviceName, + }), + }); + + if (response.json.success) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return response.json; + } else { + console.error('检查 gerenal 会员失败:', response.json.message); + return { + success: false, + message: response.json.message || '检查设备一般状态失败', + }; + } + } catch (error) { + console.error('检查 gerenal 会员失败:', error); + + // 返回错误响应格式 + return { + success: false, + message: error instanceof Error ? error.message : '检查设备状态时出现未知错误' + }; + } +}; + +// API响应类型定义 +export type UserPlanResponse = { + plan: string; + status: string; + dl_zip?: string; + [key: string]: unknown; +}; + +export type UpgradeResult = { + success: boolean; + message: string; +}; + +export const fetchUserPlan = async (apiKey: string): Promise => { + const response = await requestUrl({ + url: `${INFIO_BASE_URL}/subscription/status`, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return response.json; +} + +/** + * 清理临时目录 + */ +const cleanupTempDirectory = async (adapter: Plugin['app']['vault']['adapter'], tempDir: string): Promise => { + try { + // 检查目录是否存在 + if (await adapter.exists(tempDir)) { + console.log(`清理临时目录: ${tempDir}`); + // 删除临时目录及其所有内容 + await adapter.rmdir(tempDir, true); + console.log(`临时目录清理完成: ${tempDir}`); + } + } catch (error) { + console.log("清理临时目录失败:", error); + // 不抛出错误,因为这不是关键操作 + } +}; + + +/** + * 下载并解压ZIP文件到临时目录 + */ +const downloadAndExtractToTemp = async ( + adapter: Plugin['app']['vault']['adapter'], + tempDir: string, + downloadUrl: string +): Promise => { + console.log(`开始下载文件: ${downloadUrl}`); + + // 下载ZIP文件 + let zipResponse; + try { + zipResponse = await requestUrl({ + url: downloadUrl, + method: "GET", + }); + console.log("文件下载完成,状态:", zipResponse.status); + } catch (error) { + console.log("下载失败:", error); + throw new Error("网络连接失败,无法下载Pro版本文件"); + } + + if (!zipResponse.arrayBuffer) { + console.log("响应格式无效,缺少arrayBuffer"); + throw new Error("下载的文件格式无效"); + } + + console.log("正在解压文件到临时目录..."); + console.log(`开始解压文件到临时目录: ${tempDir}`); + + // 解压ZIP文件 + let zipData: JSZip; + try { + const zip = new JSZip(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + zipData = await zip.loadAsync(zipResponse.arrayBuffer); + console.log("ZIP文件解析成功"); + } catch (error) { + console.log("ZIP文件解析失败:", error); + throw new Error("文件解压失败,可能文件已损坏"); + } + + // 创建临时目录 + try { + if (!(await adapter.exists(tempDir))) { + await adapter.mkdir(tempDir); + console.log(`临时目录创建成功: ${tempDir}`); + } else { + console.log(`临时目录已存在: ${tempDir}`); + } + } catch (error) { + console.log("创建临时目录失败:", error); + throw new Error("无法创建临时目录"); + } + + // 解压所有文件到临时目录 + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const files = Object.keys(zipData.files); + console.log(files); + console.log(`ZIP文件中包含 ${files.length} 个条目`); + + let extractedCount = 0; + for (const filename of files) { + const file = zipData.files[filename]; + + // 跳过目录 + if (file?.dir) { + console.log(`跳过目录: ${filename}`); + continue; + } + + console.log(`正在解压文件: ${filename}`); + + // 获取文件内容 + const content = await file?.async("text"); + + if (!content) { + console.log(`跳过空文件: ${filename}`); + continue; + } + + // 提取文件名(去掉路径前缀) + const pathParts = filename.split('/'); + const actualFileName = pathParts[pathParts.length - 1]; + + // 直接写入到临时目录根目录,不创建子目录 + const tempFilePath = `${tempDir}/${actualFileName}`; + + // 写入文件到临时目录 + await adapter.write(tempFilePath, content); + extractedCount++; + console.log(`文件解压完成: ${actualFileName} (${extractedCount}/${files.filter(f => !zipData.files[f].dir).length})`); + } + + console.log(`所有文件解压完成,共解压 ${extractedCount} 个文件`); + } catch (error) { + console.log("文件解压过程中出错:", error); + throw new Error("文件解压过程中出现错误"); + } +}; + +/** + * 从临时目录复制文件到插件目录 + */ +const copyFilesFromTemp = async ( + adapter: Plugin['app']['vault']['adapter'], + tempDir: string, + pluginDir: string +): Promise => { + console.log("正在更新插件文件..."); + console.log(`开始从临时目录复制文件到插件目录: ${tempDir} -> ${pluginDir}`); + + // 需要复制的关键文件 + const filesToCopy = ['main.js', 'styles.css', 'manifest.json']; + + // 检查必需文件是否存在 + const mainJsPath = `${tempDir}/main.js`; + if (!(await adapter.exists(mainJsPath))) { + console.log("关键文件缺失: main.js"); + throw new Error("升级文件不完整,缺少关键组件"); + } + + // 复制文件 + let copiedCount = 0; + for (const filename of filesToCopy) { + const tempFilePath = `${tempDir}/${filename}`; + const pluginFilePath = `${pluginDir}/${filename}`; + + try { + if (await adapter.exists(tempFilePath)) { + const content = await adapter.read(tempFilePath); + await adapter.write(pluginFilePath, content); + copiedCount++; + } else if (filename !== 'main.js') { + console.log(`可选文件不存在,跳过: ${filename}`); + } + } catch (error) { + throw new Error(`文件更新失败: ${filename}`); + } + } + + console.log(`文件复制完成,共复制 ${copiedCount} 个文件`); +}; + +/** + * 下载并安装Pro版本 + */ +export const upgradeToProVersion = async ( + plugin: Plugin, + dl_zip: string +): Promise => { + const tempDir = '.infio_download_cache'; + const adapter = plugin.app.vault.adapter; + + try { + // 获取插件目录 + const pluginDir = plugin.manifest.dir; + if (!pluginDir) { + console.log("插件目录未找到"); + throw new Error("无法找到插件目录"); + } + new Notice("正在加载..."); + + await cleanupTempDirectory(adapter, tempDir); + + await downloadAndExtractToTemp( + adapter, + tempDir, + dl_zip + ); + + await copyFilesFromTemp(adapter, tempDir, pluginDir); + + new Notice("加载完成,成功升级"); + + await cleanupTempDirectory(adapter, tempDir); + + setTimeout(async () => { + console.log(`重载插件: ${plugin.manifest.id}`); + try { + // 禁用插件 + // @ts-expect-error obsidian typings do not expose this internal API + await plugin.app.plugins.disablePlugin(plugin.manifest.id); + console.log(`插件已禁用: ${plugin.manifest.id}`); + + // 启用插件 + // @ts-expect-error obsidian typings do not expose this internal API + await plugin.app.plugins.enablePlugin(plugin.manifest.id); + console.log(`插件已重新启用: ${plugin.manifest.id}`); + + new Notice("插件重载完成"); + } catch (error) { + console.error("插件重载失败:", error); + new Notice("插件重载失败,请手动重启插件"); + } + }, 1000); + + return { + success: true, + message: "加载完成" + }; + + } catch (error) { + console.log("错误详情:", error); + + // 发生错误时也要清理临时目录 + await cleanupTempDirectory(adapter, tempDir); + + const errorMessage = error instanceof Error ? error.message : "升级过程中出现未知错误"; + console.log(`最终错误信息: ${errorMessage}`); + new Notice(`加载失败: ${errorMessage}`); + + return { + success: false, + message: errorMessage + }; + } +} + + +export class MobileSettingTab extends PluginSettingTab { + plugin: Plugin & { settings: InfioSettings; setSettings: (s: InfioSettings) => Promise } + + constructor(app: App, plugin: Plugin & { settings: InfioSettings; setSettings: (s: InfioSet·tings) => Promise }) { + super(app, plugin) + this.plugin = plugin + } + + display(): void { + const { containerEl } = this + containerEl.empty() + + // Title + const title = containerEl.createEl('h2', { text: 'Infio Mobile' }) + title.style.marginBottom = '8px' + + // Description + containerEl.createEl('div', { text: '仅用于在移动端填写 API Key 以下载正式移动版本。' }) + + new Setting(containerEl) + .setName('Infio API Key') + .setDesc('用于验证并下载移动端正式版本') + .addText((text) => { + text + .setPlaceholder('sk-...') + .setValue(this.plugin.settings?.infioProvider?.apiKey || '') + .onChange(async (value) => { + await this.plugin.setSettings({ + ...this.plugin.settings, + infioProvider: { + ...(this.plugin.settings?.infioProvider || { name: 'Infio', apiKey: '', baseUrl: '', useCustomUrl: false, models: [] }), + apiKey: value, + }, + // 兼容字段 + infioApiKey: value, + }) + new Notice('已保存 API Key') + }) + }) + + // 升级到 Pro 按钮 + new Setting(containerEl) + .setName('升级到Pro') + .setDesc('填写 API Key 后点击下载并升级到正式移动版') + .addButton((button) => { + button.setButtonText('升级到Pro').onClick(async () => { + const originalText = button.buttonEl.textContent || '升级到Pro' + button.setDisabled(true) + button.setButtonText('加载中...') + try { + const apiKey = this.plugin.settings?.infioProvider?.apiKey || this.plugin.settings?.infioApiKey || '' + if (!apiKey) { + if (this.app) { + new ApiKeyModal(this.app).open() + } else { + new Notice('请先在Infio Provider设置中配置 Infio API Key') + } + return + } + + const userPlan = await fetchUserPlan(apiKey) + const plan = (userPlan.plan || '').toLowerCase() + const isProUser = plan.startsWith('pro') + const isGeneralUser = plan.startsWith('general') + let dl_zip = userPlan.dl_zip || '' + + if (!isProUser && !isGeneralUser) { + if (this.app) { + new ProUpgradeModal(this.app).open() + } else { + new Notice('您的账户不是会员用户, 请先购买会员') + } + return + } + + if (isGeneralUser) { + const result = await checkGeneral(apiKey) + if (!result.success) { + if (this.app) { + new ProUpgradeModal(this.app).open() + } else { + new Notice('您的账户不是会员用户, 请先购买会员') + } + return + } + dl_zip = result.dl_zip || dl_zip + } + + if (Platform.isMobile) { + dl_zip = dl_zip.replace('.zip', '.mobile.zip') + } + + if (!dl_zip) { + new Notice('无法获取下载地址,请稍后再试') + return + } + + const result = await upgradeToProVersion(this.plugin, dl_zip) + if (!result.success) { + new Notice(`加载失败: ${result.message}`) + } + } catch (_error) { + new Notice('升级过程中发生错误') + console.error(_error) + } finally { + button.setDisabled(false) + button.setButtonText(originalText) + } + }) + }) + + // Hint for users + const hint = containerEl.createEl('div', { text: Platform.isMobile ? '已检测到移动端环境。填写 API Key 后,将在插件中验证并引导下载正式版本。' : '非移动端环境。' }) + hint.style.marginTop = '8px' + ; // keep style + } +} + +export async function loadMobile(base: Plugin) { + const plugin = base as Plugin & { + settings: InfioSettings + loadSettings: () => Promise + setSettings: (s: InfioSettings) => Promise + } + + plugin.loadSettings = async function () { + this.settings = parseInfioSettings(await this.loadData()) + await this.saveData(this.settings) + } + + plugin.setSettings = async function (newSettings: InfioSettings) { + this.settings = newSettings + await this.saveData(newSettings) + } + + await plugin.loadSettings() + + // Only settings tab + plugin.addSettingTab(new MobileSettingTab(plugin.app, plugin)) +} + +export function unloadMobile(_base: Plugin) { + // nothing to cleanup in lite mobile +} + + diff --git a/src/main.ts b/src/main.ts index fbedf24..b64a1f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,685 +1,26 @@ // @ts-nocheck -import { EditorView } from '@codemirror/view' -// import { PGlite } from '@electric-sql/pglite' -import { Editor, MarkdownView, Modal, Notice, Plugin, TFile } from 'obsidian' - -import { ApplyView } from './ApplyView' -import { ChatView } from './ChatView' -import { ChatProps } from './components/chat-view/ChatView' -import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE, JSON_VIEW_TYPE, PREVIEW_VIEW_TYPE } from './constants' -import { getDiffStrategy } from "./core/diff/DiffStrategy" -import { InlineEdit } from './core/edit/inline-edit-processor' -import { McpHub } from './core/mcp/McpHub' -import { RAGEngine } from './core/rag/rag-engine' -import { TransEngine } from './core/transformations/trans-engine' -import { DBManager } from './database/database-manager' -import { migrateToJsonDatabase } from './database/json/migrateToJsonDatabase' -import { EmbeddingManager } from './embedworker/EmbeddingManager' -import EventListener from "./event-listener" -import JsonView from './JsonFileView' -import { t } from './lang/helpers' -import { PreviewView } from './PreviewView' -import CompletionKeyWatcher from "./render-plugin/completion-key-watcher" -import DocumentChangesListener, { - DocumentChanges, - getPrefix, getSuffix, - hasMultipleCursors, - hasSelection -} from "./render-plugin/document-changes-listener" -import RenderSuggestionPlugin from "./render-plugin/render-surgestion-plugin" -import { InlineSuggestionState } from "./render-plugin/states" -import { InfioSettingTab } from './settings/SettingTab' -import StatusBar from "./status-bar" -import { - InfioSettings, - parseInfioSettings, -} from './types/settings' -import { createDataviewManager, DataviewManager } from './utils/dataview' -import { getMentionableBlockData } from './utils/obsidian' -import './utils/path' -import { onEnt } from './utils/web-search' +import { Platform, Plugin } from 'obsidian' export default class InfioPlugin extends Plugin { - private metadataCacheUnloadFn: (() => void) | null = null - private activeLeafChangeUnloadFn: (() => void) | null = null - private dbManagerInitPromise: Promise | null = null - private ragEngineInitPromise: Promise | null = null - private transEngineInitPromise: Promise | null = null - private mcpHubInitPromise: Promise | null = null - settings: InfioSettings - settingTab: InfioSettingTab - settingsListeners: ((newSettings: InfioSettings) => void)[] = [] - initChatProps?: ChatProps - dbManager: DBManager | null = null - mcpHub: McpHub | null = null - ragEngine: RAGEngine | null = null - transEngine: TransEngine | null = null - embeddingManager: EmbeddingManager | null = null - inlineEdit: InlineEdit | null = null - diffStrategy?: DiffStrategy - dataviewManager: DataviewManager | null = null - async onload() { - // load settings - await this.loadSettings() - - // migrate to json database - setTimeout(() => { - void this.migrateToJsonStorage().then(() => { }) - void onEnt('loaded') - }, 100) - - // add settings tab - this.settingTab = new InfioSettingTab(this.app, this) - this.addSettingTab(this.settingTab) - - // initialize dataview manager - this.dataviewManager = createDataviewManager(this.app) - - // initialize embedding manager - this.embeddingManager = new EmbeddingManager() - console.log('EmbeddingManager initialized') - - // add icon to ribbon - this.addRibbonIcon('wand-sparkles', t('main.openInfioCopilot'), () => - this.openChatView(), - ) - - // register views - this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)) - this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf)) - this.registerView(PREVIEW_VIEW_TYPE, (leaf) => new PreviewView(leaf)) - this.registerView(JSON_VIEW_TYPE, (leaf) => new JsonView(leaf, this)) - - // register markdown processor for Inline Edit - this.inlineEdit = new InlineEdit(this, this.settings); - this.registerMarkdownCodeBlockProcessor("infioedit", (source, el, ctx) => { - this.inlineEdit?.Processor(source, el, ctx); - }); - - // setup autocomplete event listener - const statusBar = StatusBar.fromApp(this); - const eventListener = EventListener.fromSettings( - this.settings, - statusBar, - this.app - ); - - // initialize diff strategy - this.diffStrategy = getDiffStrategy( - this.settings.chatModelId || "", - this.app, - this.settings.fuzzyMatchThreshold, - this.settings.experimentalDiffStrategy, - this.settings.multiSearchReplaceDiffStrategy, - ) - - // add settings change listener - this.addSettingsListener((newSettings) => { - // Update inlineEdit when settings change - this.inlineEdit = new InlineEdit(this, newSettings); - // Update autocomplete event listener when settings change - eventListener.handleSettingChanged(newSettings) - // Update diff strategy when settings change - this.diffStrategy = getDiffStrategy( - this.settings.chatModelId || "", - this.app, - this.settings.fuzzyMatchThreshold, - this.settings.experimentalDiffStrategy, - this.settings.multiSearchReplaceDiffStrategy, - ) - // Update MCP Hub when settings change - if (this.settings.mcpEnabled && !this.mcpHub) { - void this.getMcpHub() - } else if (!this.settings.mcpEnabled && this.mcpHub) { - this.mcpHub.dispose() - this.mcpHub = null - this.mcpHubInitPromise = null - } - }); - - // setup autocomplete render plugin - this.registerEditorExtension([ - InlineSuggestionState, - CompletionKeyWatcher( - eventListener.handleAcceptKeyPressed.bind(eventListener) as () => boolean, - eventListener.handlePartialAcceptKeyPressed.bind(eventListener) as () => boolean, - eventListener.handleCancelKeyPressed.bind(eventListener) as () => boolean, - ), - DocumentChangesListener( - eventListener.handleDocumentChange.bind(eventListener) as (documentChange: DocumentChanges) => Promise - ), - RenderSuggestionPlugin(), - ]); - - this.app.workspace.onLayoutReady(() => { - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - - if (view) { - // @ts-expect-error, not typed - const editorView = view.editor.cm as EditorView; - eventListener.onViewUpdate(editorView); - } - }); - - /// *** Event Listeners *** - this.registerEvent( - this.app.workspace.on("active-leaf-change", (leaf) => { - if (leaf?.view instanceof MarkdownView) { - // @ts-expect-error, not typed - const editorView = leaf.view.editor.cm as EditorView; - eventListener.onViewUpdate(editorView); - if (leaf.view.file) { - eventListener.handleFileChange(leaf.view.file); - } - } - }) - ); - - this.registerEvent( - this.app.metadataCache.on("changed", (file: TFile) => { - if (file) { - eventListener.handleFileChange(file); - // is not worth it to update the file index on every file change - // this.ragEngine?.updateFileIndex(file); - } - }) - ); - - this.registerEvent( - this.app.metadataCache.on("deleted", (file: TFile) => { - if (file) { - this.ragEngine?.deleteFileIndex(file); - } - }) - ); - - /// *** Commands *** - this.addCommand({ - id: 'open-new-chat', - name: t('main.openNewChat'), - callback: () => this.openChatView(true), - }) - - this.addCommand({ - id: 'add-selection-to-chat', - name: t('main.addSelectionToChat'), - editorCallback: (editor: Editor, view: MarkdownView) => { - this.addSelectionToChat(editor, view) - }, - // hotkeys: [ - // { - // modifiers: ['Mod', 'Shift'], - // key: 'l', - // }, - // ], - }) - - this.addCommand({ - id: 'rebuild-vault-index', - name: t('main.rebuildVaultIndex'), - callback: async () => { - const notice = new Notice(t('notifications.rebuildingIndex'), 0) - try { - const ragEngine = await this.getRAGEngine() - await ragEngine.updateVaultIndex( - { reindexAll: true }, - (queryProgress) => { - if (queryProgress.type === 'indexing') { - const { completedChunks, totalChunks } = - queryProgress.indexProgress - notice.setMessage( - t('notifications.indexingChunks', { completedChunks, totalChunks }), - ) - } - }, - ) - notice.setMessage(t('notifications.rebuildComplete')) - } catch (error) { - console.error(error) - notice.setMessage(t('notifications.rebuildFailed')) - } finally { - setTimeout(() => { - notice.hide() - }, 1000) - } - }, - }) - - this.addCommand({ - id: 'update-vault-index', - name: t('main.updateVaultIndex'), - callback: async () => { - const notice = new Notice(t('notifications.updatingIndex'), 0) - try { - const ragEngine = await this.getRAGEngine() - await ragEngine.updateVaultIndex( - { reindexAll: false }, - (queryProgress) => { - if (queryProgress.type === 'indexing') { - const { completedChunks, totalChunks } = - queryProgress.indexProgress - notice.setMessage( - t('notifications.indexingChunks', { completedChunks, totalChunks }), - ) - } - }, - ) - notice.setMessage(t('notifications.updateComplete')) - } catch (error) { - console.error(error) - notice.setMessage(t('notifications.updateFailed')) - } finally { - setTimeout(() => { - notice.hide() - }, 1000) - } - }, - }) - - this.addCommand({ - id: 'autocomplete-accept', - name: t('main.autocompleteAccept'), - editorCheckCallback: ( - checking: boolean, - editor: Editor, - view: MarkdownView - ) => { - if (checking) { - return ( - eventListener.isSuggesting() - ); - } - - eventListener.handleAcceptCommand(); - - return true; - }, - }) - - this.addCommand({ - id: 'autocomplete-predict', - name: t('main.autocompletePredict'), - editorCheckCallback: ( - checking: boolean, - editor: Editor, - view: MarkdownView - ) => { - // @ts-expect-error, not typed - const editorView = editor.cm as EditorView; - const state = editorView.state; - if (checking) { - return eventListener.isIdle() && !hasMultipleCursors(state) && !hasSelection(state); - } - - const prefix = getPrefix(state) - const suffix = getSuffix(state) - - eventListener.handlePredictCommand(prefix, suffix); - return true; - }, - }); - - this.addCommand({ - id: "autocomplete-toggle", - name: t('main.autocompleteToggle'), - callback: () => { - const newValue = !this.settings.autocompleteEnabled; - this.setSettings({ - ...this.settings, - autocompleteEnabled: newValue, - }) - }, - }); - - this.addCommand({ - id: "autocomplete-enable", - name: t('main.autocompleteEnable'), - checkCallback: (checking) => { - if (checking) { - return !this.settings.autocompleteEnabled; - } - - this.setSettings({ - ...this.settings, - autocompleteEnabled: true, - }) - return true; - }, - }); - - this.addCommand({ - id: "autocomplete-disable", - name: t('main.autocompleteDisable'), - checkCallback: (checking) => { - if (checking) { - return this.settings.autocompleteEnabled; - } - - this.setSettings({ - ...this.settings, - autocompleteEnabled: false, - }) - return true; - }, - }); - - this.addCommand({ - id: "ai-inline-edit", - name: t('main.inlineEditCommand'), - // hotkeys: [ - // { - // modifiers: ['Mod', 'Shift'], - // key: "k", - // }, - // ], - editorCallback: (editor: Editor) => { - const selection = editor.getSelection(); - if (!selection) { - new Notice(t('notifications.selectTextFirst')); - return; - } - // Get the selection start position - const from = editor.getCursor("from"); - // Create the position for inserting the block - const insertPos = { line: from.line, ch: 0 }; - // Create the AI block with the selected text - const customBlock = "```infioedit\n```\n"; - // Insert the block above the selection - editor.replaceRange(customBlock, insertPos); - }, - }); - - // 添加简单测试命令 - this.addCommand({ - id: 'test-dataview-simple', - name: '测试 Dataview(简单查询)', - callback: async () => { - console.log('开始测试 Dataview...'); - - if (!this.dataviewManager) { - new Notice('DataviewManager 未初始化'); - return; - } - - if (!this.dataviewManager.isDataviewAvailable()) { - new Notice('Dataview 插件未安装或未启用'); - console.log('Dataview API 不可用'); - return; - } - - console.log('Dataview API 可用,执行简单查询...'); - - try { - // 执行一个最简单的查询 - const result = await this.dataviewManager.executeQuery('LIST FROM ""'); - - if (result.success) { - new Notice('Dataview 查询成功!结果已在控制台输出'); - // console.log('查询结果:', result.data); - } else { - new Notice(`查询失败: ${result.error}`); - console.error('查询错误:', result.error); - } - } catch (error) { - console.error('执行测试查询失败:', error); - new Notice('执行测试查询时发生错误'); - } - }, - }); - - // 添加本地嵌入测试命令 - this.addCommand({ - id: 'test-local-embed', - name: '测试本地嵌入模型', - callback: async () => { - try { - if (!this.embeddingManager) { - new Notice('EmbeddingManager 未初始化', 5000); - return; - } - - // 加载模型 - await this.embeddingManager.loadModel("Xenova/all-MiniLM-L6-v2", true); - - // 测试嵌入 "hello world" - const testText = "hello world"; - - const result = await this.embeddingManager.embed(testText); - - // 显示结果 - const resultMessage = ` - 嵌入测试完成! - 文本: "${testText}" - Token 数量: ${result.tokens} - 向量维度: ${result.vec.length} - 向量前4个值: [${result.vec.slice(0, 4).map(v => v.toFixed(4)).join(', ')}...] - `.trim(); - - console.log('本地嵌入测试结果:', result); - - // 创建模态框显示结果 - const modal = new Modal(this.app); - modal.titleEl.setText('本地嵌入测试结果'); - modal.contentEl.createEl('pre', { text: resultMessage }); - modal.open(); - - } catch (error) { - console.error('嵌入测试失败:', error); - new Notice(`嵌入测试失败: ${error.message}`, 5000); - } - }, - }); + if (Platform.isMobile) { + console.log('Infio Copilot: Mobile platform detected, skipping desktop-only features.') + const mod = await import('./main.mobile') + await mod.loadMobile(this) + } else { + console.log('Infio Copilot: Desktop platform detected, loading desktop features.') + const mod = await import('./main.desktop') + await mod.loadDesktop(this) + } } onunload() { - // Promise cleanup - this.dbManagerInitPromise = null - this.ragEngineInitPromise = null - this.transEngineInitPromise = null - this.mcpHubInitPromise = null - // RagEngine cleanup - this.ragEngine?.cleanup() - this.ragEngine = null - // TransEngine cleanup - this.transEngine?.cleanup() - this.transEngine = null - // Database cleanup - this.dbManager?.cleanup() - this.dbManager = null - // MCP Hub cleanup - this.mcpHub?.dispose() - this.mcpHub = null - // EmbeddingManager cleanup - this.embeddingManager?.terminate() - this.embeddingManager = null - // Dataview cleanup - this.dataviewManager = null - } - - async loadSettings() { - this.settings = parseInfioSettings(await this.loadData()) - await this.saveData(this.settings) // Save updated settings - } - - async setSettings(newSettings: InfioSettings) { - this.settings = newSettings - await this.saveData(newSettings) - this.ragEngine?.setSettings(newSettings) - this.transEngine?.setSettings(newSettings) - this.settingsListeners.forEach((listener) => listener(newSettings)) - } - - addSettingsListener( - listener: (newSettings: InfioSettings) => void, - ) { - this.settingsListeners.push(listener) - return () => { - this.settingsListeners = this.settingsListeners.filter( - (l) => l !== listener, - ) + if (Platform.isMobile) { + void import('./main.mobile').then((m) => m.unloadMobile?.(this)).catch(() => {}) + } else { + void import('./main.desktop').then((m) => m.unloadDesktop?.(this)).catch(() => {}) } } - - async openChatView(openNewChat = false) { - const view = this.app.workspace.getActiveViewOfType(MarkdownView) - const editor = view?.editor - if (!view || !editor) { - this.activateChatView(undefined, openNewChat) - return - } - const selectedBlockData = await getMentionableBlockData(editor, view) - this.activateChatView( - { - selectedBlock: selectedBlockData ?? undefined, - }, - openNewChat, - ) - } - - async activateChatView(chatProps?: ChatProps, openNewChat = false) { - // chatProps is consumed in ChatView.tsx - this.initChatProps = chatProps - - const leaf = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0] - - await (leaf ?? this.app.workspace.getRightLeaf(false))?.setViewState({ - type: CHAT_VIEW_TYPE, - active: true, - }) - - if (openNewChat && leaf && leaf.view instanceof ChatView) { - leaf.view.openNewChat(chatProps?.selectedBlock) - } - - this.app.workspace.revealLeaf( - this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0], - ) - } - - async addSelectionToChat(editor: Editor, view: MarkdownView) { - const data = await getMentionableBlockData(editor, view) - if (!data) return - - const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE) - if (leaves.length === 0 || !(leaves[0].view instanceof ChatView)) { - await this.activateChatView({ - selectedBlock: data, - }) - return - } - - // bring leaf to foreground (uncollapse sidebar if it's collapsed) - await this.app.workspace.revealLeaf(leaves[0]) - - const chatView = leaves[0].view - chatView.addSelectionToChat(data) - chatView.focusMessage() - } - - async getDbManager(): Promise { - if (this.dbManager) { - return this.dbManager - } - - if (!this.dbManagerInitPromise) { - this.dbManagerInitPromise = (async () => { - this.dbManager = await DBManager.create( - this.app, - this.settings.ragOptions.filesystem, - ) - return this.dbManager - })() - } - - // if initialization is running, wait for it to complete instead of creating a new initialization promise - return this.dbManagerInitPromise - } - - async getMcpHub(): Promise { - // MCP is not enabled - if (!this.settings.mcpEnabled) { - // new Notice('MCP is not enabled') - return null - } - - // if we already have an instance, return it - if (this.mcpHub) { - return this.mcpHub - } - - if (!this.mcpHubInitPromise) { - this.mcpHubInitPromise = (async () => { - this.mcpHub = new McpHub(this.app, this) - await this.mcpHub.onload() - return this.mcpHub - })() - } - - // if initialization is running, wait for it to complete instead of creating a new initialization promise - return this.mcpHubInitPromise - } - - async getRAGEngine(): Promise { - if (this.ragEngine) { - return this.ragEngine - } - - if (!this.ragEngineInitPromise) { - this.ragEngineInitPromise = (async () => { - const dbManager = await this.getDbManager() - this.ragEngine = new RAGEngine(this.app, this.settings, dbManager, this.embeddingManager) - return this.ragEngine - })() - } - - // if initialization is running, wait for it to complete instead of creating a new initialization promise - return this.ragEngineInitPromise - } - - async getTransEngine(): Promise { - if (this.transEngine) { - return this.transEngine - } - - if (!this.transEngineInitPromise) { - this.transEngineInitPromise = (async () => { - const dbManager = await this.getDbManager() - this.transEngine = new TransEngine(this.app, this.settings, dbManager, this.embeddingManager) - return this.transEngine - })() - } - - // if initialization is running, wait for it to complete instead of creating a new initialization promise - return this.transEngineInitPromise - } - - getEmbeddingManager(): EmbeddingManager | null { - return this.embeddingManager - } - - private async migrateToJsonStorage() { - try { - const dbManager = await this.getDbManager() - await migrateToJsonDatabase(this.app, dbManager, async () => { - await this.reloadChatView() - console.log('Migration to JSON storage completed successfully') - }) - } catch (error) { - console.error('Failed to migrate to JSON storage:', error) - new Notice( - t('notifications.migrationFailed'), - ) - } - } - - private async reloadChatView() { - const leaves = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE) - if (leaves.length === 0 || !(leaves[0].view instanceof ChatView)) { - return - } - new Notice(t('notifications.reloadingInfio'), 1000) - leaves[0].detach() - await this.activateChatView() - } } + + diff --git a/src/settings/SettingTab.tsx b/src/settings/SettingTab.tsx index 9a64655..1cc6bf3 100644 --- a/src/settings/SettingTab.tsx +++ b/src/settings/SettingTab.tsx @@ -10,7 +10,6 @@ import * as React from "react"; import { createRoot } from "react-dom/client"; import { t } from '../lang/helpers'; -import InfioPlugin from '../main'; import { InfioSettings } from '../types/settings'; import { findFilesMatchingPatterns } from '../utils/glob-utils'; @@ -24,13 +23,18 @@ import PreprocessingSettings from './components/PreprocessingSettings'; import PrivacySettings from './components/PrivacySettings'; import TriggerSettingsSection from './components/TriggerSettingsSection'; +type InfioPluginLike = Plugin & { + settings: InfioSettings; + setSettings: (s: InfioSettings) => Promise; +} + export class InfioSettingTab extends PluginSettingTab { - plugin: InfioPlugin; + plugin: InfioPluginLike; private autoCompleteContainer: HTMLElement | null = null; private modelsContainer: HTMLElement | null = null; private pluginInfoContainer: HTMLElement | null = null; - constructor(app: App, plugin: InfioPlugin) { + constructor(app: App, plugin: InfioPluginLike) { super(app, plugin) this.plugin = plugin } diff --git a/src/types/settings-mobile.ts b/src/types/settings-mobile.ts new file mode 100644 index 0000000..383f0a5 --- /dev/null +++ b/src/types/settings-mobile.ts @@ -0,0 +1,659 @@ +import { z } from 'zod'; + +import { DEFAULT_MODELS } from '../constants'; +import { + MAX_DELAY, + MAX_MAX_CHAR_LIMIT, + MIN_DELAY, + MIN_MAX_CHAR_LIMIT, + MIN_MAX_TOKENS, + fewShotExampleSchema, + modelOptionsSchema +} from '../settings/versions/shared'; +export const DEFAULT_SETTINGS = { + // version: "1", + + // General settings + autocompleteEnabled: true, + advancedMode: false, + apiProvider: "openai", + // API settings + azureOAIApiSettings: { + key: "", + url: "https://YOUR_AOI_SERVICE_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT_NAME/chat/completions", + }, + openAIApiSettings: { + key: "", + url: "https://api.openai.com/v1/chat/completions", + model: "gpt-3.5-turbo", + }, + ollamaApiSettings: { + url: "http://localhost:11434/api/chat", + model: "", + }, + + // Trigger settings + triggers: [ + { type: "string", value: "# " }, + { type: "string", value: ". " }, + { type: "string", value: ": " }, + { type: "string", value: ", " }, + { type: "string", value: "! " }, + { type: "string", value: "? " }, + { type: "string", value: "`" }, + { type: "string", value: "' " }, + { type: "string", value: "= " }, + { type: "string", value: "$ " }, + { type: "string", value: "> " }, + { type: "string", value: "\n" }, + + // bullet list + { type: "regex", value: "[\\t ]*(\\-|\\*)[\\t ]+$" }, + // numbered list + { type: "regex", value: "[\\t ]*[0-9A-Za-z]+\\.[\\t ]+$" }, + // new line with spaces + { type: "regex", value: "\\$\\$\\n[\\t ]*$" }, + // markdown multiline code block + { type: "regex", value: "```[a-zA-Z0-9]*(\\n\\s*)?$" }, + // task list normal, sub or numbered. + { type: "regex", value: "\\s*(-|[0-9]+\\.) \\[.\\]\\s+$" }, + ], + + delay: 500, + // Request settings + modelOptions: { + temperature: 1, + top_p: 0.1, + frequency_penalty: 0.25, + presence_penalty: 0, + max_tokens: MIN_MAX_TOKENS, + }, + // Prompt settings + systemMessage: `Your job is to predict the most logical text that should be written at the location of the . +Your answer can be either code, a single word, or multiple sentences. +If the is in the middle of a partial sentence, your answer should only be the 1 or 2 words fixes the sentence and not the entire sentence. +You are not allowed to have any overlapping text directly surrounding the . +Your answer must be in the same language as the text directly surrounding the . +Your response must have the following format: +THOUGHT: here, you reason about the answer; use the 80/20 principle to be brief. +LANGUAGE: here, you write the language of your answer, e.g. English, Python, Dutch, etc. +ANSWER: here, you write the text that should be at the location of +`, + fewShotExamples: [ + ], + userMessageTemplate: "{{prefix}}{{suffix}}", + chainOfThoughRemovalRegex: `(.|\\n)*ANSWER:`, + // Preprocessing settings + dontIncludeDataviews: true, + maxPrefixCharLimit: 4000, + maxSuffixCharLimit: 4000, + // Postprocessing settings + removeDuplicateMathBlockIndicator: true, + removeDuplicateCodeBlockIndicator: true, + ignoredFilePatterns: "**/secret/**\n", + ignoredTags: "", + cacheSuggestions: true, + debugMode: false, +}; +import { ApiProvider } from './llm/model'; + +export function isRegexValid(value: string): boolean { + try { + const regex = new RegExp(value); + regex.test(""); + return true; + } catch (e) { + return false; + } +} + +export function isValidIgnorePattern(value: string): boolean { + if (typeof value !== "string" || value.length === 0) return false; + // 不允许以单个反斜杠结尾 + if (/\\$/.test(value)) return false; + + const openerToCloser: Record = { "[": "]", "{": "}", "(": ")" }; + const validExtglobLeaders = new Set(["!", "?", "+", "*", "@"]); + const stack: string[] = []; + + const isEscaped = (s: string, i: number): boolean => { + let backslashes = 0; + for (let k = i - 1; k >= 0 && s[k] === "\\"; k--) backslashes++; + return backslashes % 2 === 1; + }; + + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (isEscaped(value, i)) continue; + + if (ch === "[" || ch === "{" || ch === "(") { + // 括号需作为 extglob 的一部分,如 !(...), ?(...), +(...), *(...), @(...) + if (ch === "(") { + const prev = value[i - 1]; + if (!validExtglobLeaders.has(prev ?? "")) return false; + } + stack.push(openerToCloser[ch]); + } else if (ch === "]" || ch === "}" || ch === ")") { + const expected = stack.pop(); + if (expected !== ch) return false; + } + } + + return stack.length === 0; +} +export const SETTINGS_SCHEMA_VERSION = 0.5 + +const InfioProviderSchema = z.object({ + name: z.literal('Infio'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Infio', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const OpenRouterProviderSchema = z.object({ + name: z.literal('OpenRouter'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'OpenRouter', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const SiliconFlowProviderSchema = z.object({ + name: z.literal('SiliconFlow'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'SiliconFlow', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const AlibabaQwenProviderSchema = z.object({ + name: z.literal('AlibabaQwen'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'AlibabaQwen', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const AnthropicProviderSchema = z.object({ + name: z.literal('Anthropic'), + apiKey: z.string().catch(''), + baseUrl: z.string().optional(), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Anthropic', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const DeepSeekProviderSchema = z.object({ + name: z.literal('DeepSeek'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'DeepSeek', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const GoogleProviderSchema = z.object({ + name: z.literal('Google'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Google', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const OpenAIProviderSchema = z.object({ + name: z.literal('OpenAI'), + apiKey: z.string().catch(''), + baseUrl: z.string().optional(), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'OpenAI', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const OpenAICompatibleProviderSchema = z.object({ + name: z.literal('OpenAICompatible'), + apiKey: z.string().catch(''), + baseUrl: z.string().optional(), + useCustomUrl: z.boolean().catch(true), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'OpenAICompatible', + apiKey: '', + baseUrl: '', + useCustomUrl: true, + models: [] +}) + +const OllamaProviderSchema = z.object({ + name: z.literal('Ollama'), + apiKey: z.string().catch('ollama'), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Ollama', + apiKey: 'ollama', + baseUrl: '', + useCustomUrl: true, + models: [] +}) + +const GroqProviderSchema = z.object({ + name: z.literal('Groq'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Groq', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const GrokProviderSchema = z.object({ + name: z.literal('Grok'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Grok', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const MoonshotProviderSchema = z.object({ + name: z.literal('Moonshot'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'Moonshot', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const LocalProviderSchema = z.object({ + name: z.literal('LocalProvider'), + apiKey: z.string().catch(''), + baseUrl: z.string().catch(''), + useCustomUrl: z.boolean().catch(false), + models: z.array(z.string()).catch([]) +}).catch({ + name: 'LocalProvider', + apiKey: '', + baseUrl: '', + useCustomUrl: false, + models: [] +}) + +const ollamaModelSchema = z.object({ + baseUrl: z.string().catch(''), + model: z.string().catch(''), +}) + +const openAICompatibleModelSchema = z.object({ + baseUrl: z.string().catch(''), + apiKey: z.string().catch(''), + model: z.string().catch(''), +}) + +const ragOptionsSchema = z.object({ + filesystem: z.enum(['idb', 'opfs']).catch('opfs'), + chunkSize: z.number().catch(500), + batchSize: z.number().catch(32), + thresholdTokens: z.number().catch(8192), + minSimilarity: z.number().catch(0.0), + limit: z.number().catch(10), + excludePatterns: z.array(z.string()).catch([]), + includePatterns: z.array(z.string()).catch([]), +}) + +export const triggerSchema = z.object({ + type: z.enum(['string', 'regex']), + value: z.string().min(1, { message: "Trigger value must be at least 1 character long" }) +}).strict().superRefine((trigger, ctx) => { + if (trigger.type === "regex") { + if (!trigger.value.endsWith("$")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Regex triggers must end with a $.", + path: ["value"], + }); + } + if (!isRegexValid(trigger.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid regex: "${trigger.value}"`, + path: ["value"], + }); + } + } +}); + +const FilesSearchSettingsSchema = z.object({ + method: z.enum(['match', 'regex', 'semantic', 'auto']).catch('auto'), + regexBackend: z.enum(['coreplugin', 'ripgrep']).catch('coreplugin'), + matchBackend: z.enum(['omnisearch', 'coreplugin']).catch('coreplugin'), + ripgrepPath: z.string().catch(''), +}).catch({ + method: 'auto', + regexBackend: 'coreplugin', + matchBackend: 'coreplugin', + ripgrepPath: '', +}); + +export const InfioSettingsSchema = z.object({ + // Version + version: z.literal(SETTINGS_SCHEMA_VERSION).catch(SETTINGS_SCHEMA_VERSION), + + // Provider + defaultProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + infioProvider: InfioProviderSchema, + openrouterProvider: OpenRouterProviderSchema, + siliconflowProvider: SiliconFlowProviderSchema, + alibabaQwenProvider: AlibabaQwenProviderSchema, + anthropicProvider: AnthropicProviderSchema, + deepseekProvider: DeepSeekProviderSchema, + openaiProvider: OpenAIProviderSchema, + googleProvider: GoogleProviderSchema, + ollamaProvider: OllamaProviderSchema, + groqProvider: GroqProviderSchema, + grokProvider: GrokProviderSchema, + moonshotProvider: MoonshotProviderSchema, + openaicompatibleProvider: OpenAICompatibleProviderSchema, + localproviderProvider: LocalProviderSchema, + + // MCP Servers + mcpEnabled: z.boolean().catch(false), + + // Chat Model start list + collectedChatModels: z.array(z.object({ + provider: z.nativeEnum(ApiProvider), + modelId: z.string(), + })).catch([]), + + // Insight Model start list + collectedInsightModels: z.array(z.object({ + provider: z.nativeEnum(ApiProvider), + modelId: z.string(), + })).catch([]), + + // Apply Model start list + collectedApplyModels: z.array(z.object({ + provider: z.nativeEnum(ApiProvider), + modelId: z.string(), + })).catch([]), + + // Embedding Model start list + collectedEmbeddingModels: z.array(z.object({ + provider: z.nativeEnum(ApiProvider), + modelId: z.string(), + })).catch([]), + + // Active Provider Tab (for UI state) + activeProviderTab: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + + // Chat Model + chatModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + chatModelId: z.string().catch(''), + + // Insight Model + insightModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + insightModelId: z.string().catch(''), + + // Apply Model + applyModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + applyModelId: z.string().catch(''), + + // Embedding Model + embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Infio), + embeddingModelId: z.string().catch(''), + + // fuzzyMatchThreshold + fuzzyMatchThreshold: z.number().catch(0.85), + + // experimentalDiffStrategy + experimentalDiffStrategy: z.boolean().catch(false), + + // multiSearchReplaceDiffStrategy + multiSearchReplaceDiffStrategy: z.boolean().catch(true), + + // Workspace + workspace: z.string().catch(''), + // Mode + mode: z.string().catch('ask'), + defaultMention: z.enum(['none', 'current-file', 'vault']).catch('none'), + + // web search + serperApiKey: z.string().catch(''), + serperSearchEngine: z.enum(['google', 'duckduckgo', 'bing']).catch('google'), + jinaApiKey: z.string().catch(''), + + // Files Search + filesSearchSettings: FilesSearchSettingsSchema, + + /// [compatible] + // activeModels [compatible] + activeModels: z.array( + z.object({ + name: z.string(), + provider: z.string(), + enabled: z.boolean(), + isEmbeddingModel: z.boolean(), + isBuiltIn: z.boolean(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + dimension: z.number().optional(), + }) + ).catch(DEFAULT_MODELS), + // API Keys [compatible] + infioApiKey: z.string().catch(''), + openAIApiKey: z.string().catch(''), + anthropicApiKey: z.string().catch(''), + geminiApiKey: z.string().catch(''), + groqApiKey: z.string().catch(''), + deepseekApiKey: z.string().catch(''), + ollamaEmbeddingModel: ollamaModelSchema.catch({ + baseUrl: '', + model: '', + }), + ollamaChatModel: ollamaModelSchema.catch({ + baseUrl: '', + model: '', + }), + openAICompatibleChatModel: openAICompatibleModelSchema.catch({ + baseUrl: '', + apiKey: '', + model: '', + }), + ollamaApplyModel: ollamaModelSchema.catch({ + baseUrl: '', + model: '', + }), + openAICompatibleApplyModel: openAICompatibleModelSchema.catch({ + baseUrl: '', + apiKey: '', + model: '', + }), + + // System Prompt + systemPrompt: z.string().catch(''), + + // RAG Options + ragOptions: ragOptionsSchema.catch({ + filesystem: 'opfs', + batchSize: 32, + chunkSize: 500, + thresholdTokens: 8192, + minSimilarity: 0.0, + limit: 10, + excludePatterns: [], + includePatterns: [], + }), + + // autocomplete options + autocompleteEnabled: z.boolean(), + advancedMode: z.boolean(), + + // [compatible] + apiProvider: z.enum(['azure', 'openai', "ollama"]), + azureOAIApiSettings: z.string().catch(''), + openAIApiSettings: z.string().catch(''), + ollamaApiSettings: z.string().catch(''), + + triggers: z.array(triggerSchema), + delay: z.number().int().min(MIN_DELAY, { message: "Delay must be between 0ms and 2000ms" }).max(MAX_DELAY, { message: "Delay must be between 0ms and 2000ms" }), + modelOptions: modelOptionsSchema, + systemMessage: z.string().min(3, { message: "System message must be at least 3 characters long" }), + fewShotExamples: z.array(fewShotExampleSchema), + userMessageTemplate: z.string().min(3, { message: "User message template must be at least 3 characters long" }), + chainOfThoughRemovalRegex: z.string().refine((regex) => isRegexValid(regex), { message: "Invalid regex" }), + dontIncludeDataviews: z.boolean(), + maxPrefixCharLimit: z.number().int().min(MIN_MAX_CHAR_LIMIT, { message: `Max prefix char limit must be at least ${MIN_MAX_CHAR_LIMIT}` }).max(MAX_MAX_CHAR_LIMIT, { message: `Max prefix char limit must be at most ${MAX_MAX_CHAR_LIMIT}` }), + maxSuffixCharLimit: z.number().int().min(MIN_MAX_CHAR_LIMIT, { message: `Max prefix char limit must be at least ${MIN_MAX_CHAR_LIMIT}` }).max(MAX_MAX_CHAR_LIMIT, { message: `Max prefix char limit must be at most ${MAX_MAX_CHAR_LIMIT}` }), + removeDuplicateMathBlockIndicator: z.boolean(), + removeDuplicateCodeBlockIndicator: z.boolean(), + ignoredFilePatterns: z.string().refine((value) => value + .split("\n") + .filter(s => s.trim().length > 0) + .filter(s => !isValidIgnorePattern(s)).length === 0, + { message: "Invalid ignore pattern" } + ), + ignoredTags: z.string().refine((value) => value + .split("\n") + .filter(s => s.includes(" ")).length === 0, { message: "Tags cannot contain spaces" } + ).refine((value) => value + .split("\n") + .filter(s => s.includes("#")).length === 0, { message: "Enter tags without the # symbol" } + ).refine((value) => value + .split("\n") + .filter(s => s.includes(",")).length === 0, { message: "Enter each tag on a new line without commas" } + ), + cacheSuggestions: z.boolean(), + debugMode: z.boolean(), +}) + +export type InfioSettings = z.infer +export type FilesSearchSettings = z.infer + +type Migration = { + fromVersion: number + toVersion: number + migrate: (data: Record) => Record +} + +const MIGRATIONS: Migration[] = [ + { + fromVersion: 0.1, + toVersion: 0.4, + migrate: (data) => { + const newData = { ...data } + newData.version = 0.4 + return newData + }, + }, + { + fromVersion: 0.4, + toVersion: 0.5, + migrate: (data) => { + const newData = { ...data } + newData.version = SETTINGS_SCHEMA_VERSION + + // Handle max_tokens minimum value increase from 800 to 4096 + if (newData.modelOptions && typeof newData.modelOptions === 'object') { + const modelOptions = newData.modelOptions as Record + if (typeof modelOptions.max_tokens === 'number' && modelOptions.max_tokens < MIN_MAX_TOKENS) { + console.log(`Updating max_tokens from ${modelOptions.max_tokens} to ${MIN_MAX_TOKENS} due to minimum value change`) + modelOptions.max_tokens = MIN_MAX_TOKENS + } + } + + return newData + }, + }, +] + +function migrateSettings( + data: Record, +): Record { + let currentData = { ...data } + const currentVersion = (currentData.version as number) ?? 0 + + for (const migration of MIGRATIONS) { + if ( + currentVersion >= migration.fromVersion && + currentVersion < migration.toVersion && + migration.toVersion <= SETTINGS_SCHEMA_VERSION + ) { + console.debug( + `Migrating settings from ${migration.fromVersion} to ${migration.toVersion}`, + ) + currentData = migration.migrate(currentData) + } + } + + return currentData +} + +export function parseInfioSettings(data: unknown): InfioSettings { + try { + const migratedData = migrateSettings(data as Record) + return InfioSettingsSchema.parse(migratedData) + } catch (error) { + console.error("Failed to parse settings with migrated data, using default settings instead: ", error); + return InfioSettingsSchema.parse({ ...DEFAULT_SETTINGS }) + } +} diff --git a/src/utils/device-id.ts b/src/utils/device-id.ts new file mode 100644 index 0000000..1dc1eaf --- /dev/null +++ b/src/utils/device-id.ts @@ -0,0 +1,84 @@ +import { Platform } from 'obsidian' + +const DEVICE_ID_STORAGE_KEY = 'infio_device_id' + +function generatePseudoId(): string { + // RFC4122-ish v4 UUID (non-crypto), sufficient for stable device identifier when persisted + let timeSeed = Date.now() + let perfSeed = (typeof performance !== 'undefined' && typeof performance.now === 'function') + ? Math.floor(performance.now() * 1000) + : 0 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => { + let rand = Math.random() * 16 + if (timeSeed > 0) { + rand = (timeSeed + rand) % 16 + timeSeed = Math.floor(timeSeed / 16) + } else { + rand = (perfSeed + rand) % 16 + perfSeed = Math.floor(perfSeed / 16) + } + const value = ch === 'x' ? rand : (rand & 0x3) | 0x8 + return Math.floor(value).toString(16) + }) +} + +function safeGetLocalStorage(key: string): string | null { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return window.localStorage.getItem(key) + } + } catch { /* noop */ } + return null +} + +function safeSetLocalStorage(key: string, value: string): void { + try { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.setItem(key, value) + } + } catch { /* noop */ } +} + +export async function getDeviceId(): Promise { + // On mobile, generate and persist a stable pseudo ID + if (Platform.isMobile) { + const existing = safeGetLocalStorage(DEVICE_ID_STORAGE_KEY) + if (existing) return existing + const generated = generatePseudoId() + safeSetLocalStorage(DEVICE_ID_STORAGE_KEY, generated) + return generated + } + + // Desktop: try node-machine-id; fall back to persisted pseudo ID + try { + const moduleName = 'node-machine-id' + // Use dynamic import via variable to avoid bundlers pulling it into mobile builds + const mod: unknown = await import(/* @vite-ignore */ moduleName) + if ( + typeof mod === 'object' && mod !== null && + 'machineId' in mod && typeof (mod as Record).machineId === 'function' + ) { + const id = await (mod as { machineId: () => Promise }).machineId() + if (id) return id + } + } catch { + // ignore and fall back + } + + const existing = safeGetLocalStorage(DEVICE_ID_STORAGE_KEY) + if (existing) return existing + const generated = generatePseudoId() + safeSetLocalStorage(DEVICE_ID_STORAGE_KEY, generated) + return generated +} + +export function getOperatingSystem(): string { + if (Platform.isWin) return 'windows' + if (Platform.isMacOS) return 'macos' + if (Platform.isLinux) return 'linux' + if (Platform.isAndroidApp) return 'android' + if (Platform.isIosApp) return 'ios' + return 'unknown' +} + + diff --git a/styles.css b/styles.css index 01aa818..2b9b319 100644 --- a/styles.css +++ b/styles.css @@ -73,7 +73,8 @@ display: flex; justify-content: space-between; align-items: center; - margin-top: calc(var(--size-4-1) * -1); + margin-top: calc(var(--size-2-1) * -1); + padding: 0 var(--size-2-2); } .infio-chat-header-title { @@ -89,8 +90,8 @@ .infio-chat-current-workspace { display: inline-flex; align-items: center; - margin-left: var(--size-4-2); - padding: var(--size-2-1) var(--size-4-1); + margin-left: var(--size-2-2); + padding: var(--size-2-1) var(--size-2-2); background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); @@ -102,11 +103,12 @@ text-overflow: ellipsis; white-space: nowrap; transition: all 0.2s ease; + min-height: 28px; } .infio-chat-header-buttons { display: flex; - gap: var(--size-2-1); + gap: var(--size-1-1); } .infio-chat-container { @@ -118,12 +120,21 @@ .infio-stop-gen-btn { z-index: 1000; position: absolute; - bottom: 160px; + bottom: 120px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; - gap: var(--size-4-1); + gap: var(--size-2-2); + padding: var(--size-2-2) var(--size-4-3); + background-color: var(--background-modifier-error); + color: var(--text-on-accent); + border: none; + border-radius: var(--radius-l); + font-size: var(--font-ui-medium); + min-height: 44px; + cursor: pointer; + transition: all 0.2s ease; } } @@ -139,8 +150,8 @@ display: flex; flex-direction: column; gap: var(--size-2-1); - padding: 0 var(--size-4-3) var(--size-4-5) var(--size-4-3); - margin: var(--size-4-2) calc(var(--size-4-3) * -1) 0; + padding: 0 var(--size-2-2) var(--size-4-3) var(--size-2-2); + margin: var(--size-2-2) calc(var(--size-2-2) * -1) 0; /* 确保内容不会超出容器 */ min-width: 0; @@ -158,7 +169,7 @@ .infio-chat-messages-assistant { display: flex; flex-direction: column; - padding: var(--size-2-2); + /* 防止助手消息超出容器 */ min-width: 0; max-width: 100%; } @@ -170,7 +181,8 @@ */ .infio-starred-commands { border: none !important; - margin-bottom: var(--size-4-2); + margin-bottom: var(--size-2-2); + padding: 0 var(--size-2-2); } .infio-starred-commands-title { @@ -193,17 +205,18 @@ display: flex; align-items: center; justify-content: center; - - padding: 1px 8px; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-3); background-color: rgba(86, 72, 29, 0.3) !important; border: none !important; box-shadow: none !important; border-radius: var(--radius-l); - font-size: var(--font-ui-small); + font-size: var(--font-ui-medium); color: #f1c43f; cursor: pointer; transition: all 0.2s ease; - max-width: 200px; + min-height: 40px; + max-width: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -581,16 +594,20 @@ button:not(.clickable-icon).infio-chat-list-dropdown { display: flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 36px; + height: 36px; padding: 0; background-color: transparent; border-color: transparent; box-shadow: none; color: var(--text-muted); + border-radius: var(--radius-s); + transition: all 0.2s ease; - &:hover { + &:hover, + &:active { background-color: var(--background-modifier-hover); + color: var(--text-normal); } } @@ -618,10 +635,11 @@ button:not(.clickable-icon).infio-chat-list-dropdown { border: var(--input-border-width) solid var(--background-modifier-border); color: var(--text-normal); font-family: inherit; - padding: calc(var(--size-2-2) + 1px); + padding: var(--size-2-1); font-size: var(--font-ui-medium); border-radius: var(--radius-s); outline: none; + margin: 0 var(--size-2-2) var(--size-2-2) var(--size-2-2); &:focus-within, &:focus, @@ -635,18 +653,18 @@ button:not(.clickable-icon).infio-chat-list-dropdown { .infio-chat-user-input-files { display: flex; flex-direction: row; - gap: var(--size-4-1); + gap: var(--size-2-1); flex-wrap: wrap; - padding-bottom: var(--size-4-1); + padding-bottom: var(--size-2-1); } .infio-chat-user-input-controls { display: flex; flex-direction: row; - gap: var(--size-4-1); + gap: var(--size-2-1); justify-content: space-between; align-items: center; - height: var(--size-4-4); + height: 36px; .infio-chat-user-input-controls__model-select-container { display: flex; @@ -658,7 +676,7 @@ button:not(.clickable-icon).infio-chat-list-dropdown { .infio-chat-user-input-controls__buttons { flex-shrink: 0; display: flex; - gap: var(--size-4-3); + gap: var(--size-2-1); align-items: center; } } @@ -666,20 +684,23 @@ button:not(.clickable-icon).infio-chat-list-dropdown { .infio-chat-user-input-controls .infio-chat-user-input-submit-button { display: flex; align-items: center; - gap: var(--size-4-1); - font-size: var(--font-smallest); + gap: var(--size-2-1); + font-size: var(--font-ui-smaller); color: var(--text-muted); background-color: transparent; border: none; box-shadow: none; - padding: 0 var(--size-2-1); + padding: var(--size-2-1) var(--size-2-2); border-radius: var(--radius-s); - height: var(--size-4-4); + height: 32px; + min-width: 36px; cursor: pointer; - transition: color 0.15s ease-in-out; + transition: all 0.2s ease; - &:hover { + &:hover, + &:active { color: var(--text-normal); + background-color: var(--background-modifier-hover); } .infio-chat-user-input-submit-button-icons { @@ -691,20 +712,23 @@ button:not(.clickable-icon).infio-chat-list-dropdown { .infio-chat-user-input-controls .infio-chat-user-input-vault-button { display: flex; align-items: center; - gap: var(--size-4-1); - font-size: var(--font-smallest); + gap: var(--size-2-1); + font-size: var(--font-ui-smaller); color: var(--text-muted); background-color: var(--background-secondary-alt) !important; border: none; box-shadow: none; - padding: 0 var(--size-2-1); + padding: var(--size-2-1) var(--size-2-2); border-radius: var(--radius-s); - height: var(--size-4-4); + height: 32px; + min-width: 36px; cursor: pointer; - transition: color 0.15s ease-in-out; + transition: all 0.2s ease; - &:hover { + &:hover, + &:active { color: var(--text-normal); + background-color: var(--background-modifier-hover) !important; } .infio-chat-user-input-vault-button-icons { @@ -719,12 +743,13 @@ button:not(.clickable-icon).infio-chat-list-dropdown { background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); - font-size: var(--font-smallest); - padding: var(--size-2-1) var(--size-4-1); + font-size: var(--font-ui-smaller); + padding: var(--size-2-1) var(--size-2-2); gap: var(--size-2-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-height: 28px; &.infio-chat-user-input-file-badge-focused { border: 1px solid var(--interactive-accent); @@ -791,23 +816,27 @@ button:not(.clickable-icon).infio-chat-list-dropdown { .infio-chat-edit-cancel-button { position: absolute; - top: 8px; - right: 8px; + top: var(--size-2-1); + right: var(--size-2-1); z-index: 10; display: flex; align-items: center; justify-content: center; - color: var(--text-on-accent-hover); - background-color: var(--interactive-accent-hover); + background-color: transparent !important; border: none !important; box-shadow: none !important; + color: var(--text-muted); padding: 0 !important; margin: 0 !important; - width: 24px !important; - height: 24px !important; + width: 44px !important; + height: 44px !important; + border-radius: var(--radius-m) !important; + transition: all 0.2s ease !important; - &:hover { - background-color: var(--interactive-accent-hover) !important; + &:hover, + &:active { + background-color: var(--background-modifier-hover) !important; + color: var(--text-normal) !important; } } @@ -991,15 +1020,11 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { gap: var(--size-4-1); } -/* =========================================== - 代码块样式 - 统一管理所有代码块相关样式 - =========================================== */ - -/* 基础代码块容器 */ .infio-chat-code-block { position: relative; border: 1px solid var(--background-modifier-border); border-radius: var(--radius-s); + /* 防止代码块内容溢出 */ overflow: hidden; word-wrap: break-word; } @@ -1008,43 +1033,70 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { padding: 0; } -/* RawMarkdownBlock 专用代码块容器 */ +/* 删除冲突的样式定义 */ + +/* RawMarkdownBlock 代码块容器样式 */ .infio-raw-markdown-code-block { - position: relative; - margin: 0; - padding: 0; - width: 100%; - overflow: visible; + position: relative !important; + z-index: 100 !important; /* 提高z-index优先级 */ + margin: 0 !important; + padding: 0 !important; + /* 确保代码块不会被其他元素遮挡 */ clear: both; - box-sizing: border-box; - /* 创建新的层叠上下文,避免z-index冲突 */ - isolation: isolate; -} - -/* RawMarkdownBlock 内的 pre 元素 */ -.infio-raw-markdown-code-block pre { - position: relative; - margin: 0; - padding: 0; - width: 100%; overflow: visible; -} - -/* RawMarkdownBlock 内的 code 元素 */ -.infio-raw-markdown-code-block code { - position: relative; - display: block; + /* 添加背景色以确保可见性 */ + background: transparent; + /* 确保不被截断 */ width: 100%; + box-sizing: border-box; +} + +/* 确保代码块内容正确显示 */ +.infio-raw-markdown-code-block pre { + position: relative !important; + z-index: 101 !important; + margin: 0 !important; + padding: 0 !important; + /* 确保完整显示 */ + width: 100% !important; + overflow: visible !important; +} + +.infio-raw-markdown-code-block code { + position: relative !important; + z-index: 102 !important; + /* 确保代码内容完整显示 */ + display: block !important; + width: 100% !important; +} + +.infio-reasoning-content-wrapper { + /* Height restrictions removed to show full content */ + /* 防止推理内容溢出 */ + overflow-x: auto; + overflow-y: visible; +} + +/* 折叠/展开功能相关样式 */ +.infio-reasoning-content-wrapper.collapsed { + max-height: 150px; + overflow-y: auto; + transition: max-height 0.3s ease-in-out; +} + +.infio-reasoning-content-wrapper.expanded { + max-height: none; + overflow-y: visible; + transition: max-height 0.3s ease-in-out; } -/* 代码块头部样式 */ .infio-chat-code-block-header { display: none; justify-content: space-between; align-items: center; font-size: var(--font-smallest); padding: 0; - margin: 0; + margin: 0; } .infio-chat-code-block:hover .infio-chat-code-block-header { @@ -1068,7 +1120,6 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { left: 0; } -/* 代码块头部文件名样式 */ .infio-chat-code-block-header-filename { padding-left: var(--size-4-2); font-size: var(--font-medium); @@ -1085,7 +1136,6 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { flex-shrink: 0; } -/* 代码块头部按钮样式 */ .infio-chat-code-block-header-button { display: flex; gap: var(--size-4-1); @@ -1105,7 +1155,9 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { padding-right: var(--size-4-1); } -.infio-chat-code-block.has-filename .infio-chat-code-block-header-button button { +.infio-chat-code-block.has-filename + .infio-chat-code-block-header-button + button { box-shadow: none; border: 0; padding: 0 var(--size-4-2); @@ -1114,10 +1166,10 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { font-size: var(--font-medium); height: 100%; cursor: pointer; -} -.infio-chat-code-block.has-filename .infio-chat-code-block-header-button button:hover { - background-color: var(--background-modifier-hover); + &:hover { + background-color: var(--background-modifier-hover); + } } .infio-chat-code-block-header-button button { @@ -1126,36 +1178,12 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { font-size: var(--font-ui-smaller); } -/* 代码块内容样式 */ -.infio-chat-code-block-content { - margin: 0; -} - -/* 特殊按钮样式 */ .infio-dataview-query-button { color: #008000; } -/* =========================================== - 推理内容包装器样式 - =========================================== */ - -.infio-reasoning-content-wrapper { - overflow-x: auto; - overflow-y: visible; -} - -/* 折叠/展开功能相关样式 */ -.infio-reasoning-content-wrapper.collapsed { - max-height: 150px; - overflow-y: auto; - transition: max-height 0.3s ease-in-out; -} - -.infio-reasoning-content-wrapper.expanded { - max-height: none; - overflow-y: visible; - transition: max-height 0.3s ease-in-out; +.infio-chat-code-block-content { + margin: 0; } /* Read File Block - Minimal styling for better integration */ @@ -1536,11 +1564,11 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { */ .infio-chat-lexical-content-editable-root { - min-height: 62px; - max-height: 800px; + min-height: 36px; + max-height: 400px; overflow-y: auto; overflow-x: hidden; - + padding: var(--size-2-1); } .infio-chat-lexical-content-editable-root .mention { @@ -1556,10 +1584,11 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { } .infio-search-lexical-content-editable-root { - min-height: 36px; - max-height: 120px; + min-height: 40px; + max-height: 300px; overflow-y: auto; overflow-x: hidden; + padding: var(--size-2-1); /* 确保编辑器不会影响外部滚动 */ contain: size style; } @@ -1713,22 +1742,27 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { display: flex; align-items: center; justify-content: end; + gap: var(--size-2-1); button { display: flex; align-items: center; justify-content: center; - height: 20px; - width: 20px; + height: 44px; + width: 44px; padding: 0; background-color: transparent; border-color: transparent; box-shadow: none; color: var(--text-faint); cursor: pointer; + border-radius: var(--radius-m); + transition: all 0.2s ease; - &:hover { + &:hover, + &:active { background-color: var(--background-modifier-hover); + color: var(--text-normal); } } @@ -2281,6 +2315,49 @@ input[type='text'].infio-chat-list-dropdown-item-title-input { width: 70%; } +/* 移动端专用优化 */ + +/* 增加触摸目标的最小尺寸 */ +button, .clickable-icon, [role="button"] { + min-height: 36px; + min-width: 36px; + touch-action: manipulation; +} + +/* 输入框按钮使用较小的尺寸以节省空间 */ +.infio-chat-user-input-controls button, +.infio-chat-user-input-controls .clickable-icon { + min-height: 32px; + min-width: 32px; +} + +/* 聊天头部按钮保持36px尺寸 */ +.infio-chat-header-buttons button { + min-height: 36px; + min-width: 36px; +} + +/* 优化滚动性能 */ +.infio-chat-messages, +.infio-chat-lexical-content-editable-root, +.infio-search-lexical-content-editable-root { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* 优化文本选择 */ +.infio-chat-messages { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +/* 防止双击缩放 */ +button, .clickable-icon { + touch-action: manipulation; +} + /* control pc and mobile view */ .desktop-only { @@ -2686,20 +2763,21 @@ button.infio-chat-input-model-select { box-shadow: none; border: 1; padding: var(--size-2-1) var(--size-2-2); - font-size: var(--font-smallest); + font-size: var(--font-ui-smaller); font-weight: var(--font-medium); color: var(--text-muted); display: flex; justify-content: flex-start; align-items: center; cursor: pointer; - height: var(--size-4-4); + height: 32px; max-width: 100%; - gap: var(--size-2-2); + gap: var(--size-2-1); border-radius: var(--radius-s); - transition: all 0.15s ease-in-out; + transition: all 0.2s ease; - &:hover { + &:hover, + &:active { color: var(--text-normal); background-color: var(--background-modifier-hover); } @@ -2799,6 +2877,7 @@ button.infio-chat-input-model-select { align-items: center; height: 100%; width: 100%; + padding: var(--size-4-3); } .infio-chat-loading-container { @@ -2998,8 +3077,10 @@ button.infio-chat-input-model-select { } .infio-commands-header-title { - margin: 0; - font-size: 24px; + color: var(--text-normal); + font-size: 28px; + font-weight: 500; + margin: 0 0 var(--size-4-3) 0; } .infio-commands-label { @@ -3655,18 +3736,18 @@ button.infio-chat-input-model-select { .infio-markdown-actions { position: absolute; - bottom: 1px; - right: 1px; + top: 8px; + right: 8px; display: flex; gap: 4px; opacity: 0.7; visibility: visible; + transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; padding: 0px; - z-index: 10; /* 降低z-index,确保不会遮挡代码块 */ + z-index: 50; /* 降低z-index,确保不会遮挡代码块 */ /* 确保不会遮挡代码块 */ pointer-events: none; } - .infio-markdown-actions button { display: flex; align-items: center; @@ -3677,19 +3758,18 @@ button.infio-chat-input-model-select { background-color: transparent; border-color: transparent; box-shadow: none; - color: var(--text-muted); - border-radius: var(--radius-s); + color: var(--text-faint); cursor: pointer; /* 恢复按钮的点击事件 */ pointer-events: auto; +} - &:hover { - background-color: var(--interactive-accent-hover); - } - } +.infio-markdown-actions button:hover { + background-color: var(--background-modifier-hover); +} -.infio-chat-message-actions-icon--copied { - color: var(--text-muted); +.infio-markdown-actions .infio-chat-message-actions-icon--copied { + color: var(--text-muted); } .infio-markdown-container-with-actions:hover .infio-markdown-actions { @@ -3957,136 +4037,3 @@ button.infio-chat-input-model-select { transform: translateY(0); } } - -/* Structured Progress Styles */ -.infio-structured-progress { - padding: 16px; - background-color: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-m); - margin: 12px 0; -} - -.infio-progress-steps { - display: flex; - flex-direction: column; - gap: 0; -} - -.infio-progress-step { - display: flex; - align-items: flex-start; - position: relative; - min-height: 60px; -} - -.infio-progress-step-indicator { - display: flex; - flex-direction: column; - align-items: center; - margin-right: 16px; - position: relative; - flex-shrink: 0; -} - -.infio-progress-step-icon { - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - z-index: 2; - position: relative; -} - -.infio-progress-step-line { - width: 2px; - height: 40px; - background-color: var(--background-modifier-border); - margin-top: 8px; -} - -.infio-progress-step-line.completed { - background-color: var(--color-green, #28a745); -} - -.infio-progress-step-content { - flex: 1; - min-width: 0; - padding-top: 4px; -} - -.infio-progress-step-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 4px; -} - -.infio-progress-step-title { - font-size: var(--font-ui-medium); - font-weight: 600; - color: var(--text-normal); - margin: 0; - line-height: 1.3; -} - -.infio-progress-step.running .infio-progress-step-title { - color: var(--interactive-accent); -} - -.infio-progress-step-counter { - font-size: var(--font-ui-small); - color: var(--text-muted); - font-family: var(--font-monospace); - background-color: var(--background-modifier-border); - padding: 2px 6px; - border-radius: var(--radius-s); -} - -.infio-progress-step-description { - font-size: var(--font-ui-small); - color: var(--text-muted); - margin: 4px 0 0 0; - line-height: 1.4; -} - -.infio-progress-step-details { - font-size: var(--font-ui-smaller); - color: var(--text-faint); - margin: 2px 0 0 0; - line-height: 1.3; - font-family: var(--font-monospace); -} - -.infio-progress-step-bar { - width: 100%; - height: 4px; - background-color: var(--background-modifier-border); - border-radius: 2px; - margin-top: 8px; - overflow: hidden; -} - -.infio-progress-step-bar-fill { - height: 100%; - background-color: var(--interactive-accent); - border-radius: 2px; - transition: width 0.3s ease; -} - -/* Tool result content display */ -.infio-tool-result-content { - font-family: var(--font-monospace); - font-size: 13px; - line-height: 1.5; - margin: 0; - padding: 12px; - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: var(--radius-s); - white-space: pre-wrap; - word-break: break-word; - overflow: auto; -}