add mobile version for pro user

This commit is contained in:
duanfuxiang 2025-09-23 16:00:22 +08:00
parent 1483b3b8b9
commit 669656e138
11 changed files with 2051 additions and 974 deletions

View File

@ -1,4 +1,7 @@
releases: releases:
- version: "0.8.5"
features:
- "add mobile version for pro user, fix update error"
- version: "0.8.4" - version: "0.8.4"
features: features:
- "test mobile version" - "test mobile version"

View File

@ -1,11 +1,11 @@
{ {
"id": "infio-copilot", "id": "infio-copilot",
"name": "Infio Copilot", "name": "Infio Copilot",
"version": "0.8.4", "version": "0.8.5",
"minAppVersion": "0.15.0", "minAppVersion": "0.15.0",
"description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes", "description": "A Cursor-inspired AI assistant for notes that offers smart autocomplete and interactive chat with your selected notes",
"author": "Felix.D", "author": "Felix.D",
"authorUrl": "https://github.com/infiolab", "authorUrl": "https://github.com/infiolab",
"isDesktopOnly": false "isDesktopOnly": true
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "obsidian-infio-copilot", "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", "description": "A Cursor-inspired AI assistant that offers smart autocomplete and interactive chat with your selected notes",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -1,29 +1,9 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ /* 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 JSZip from "jszip";
import { machineId } from 'node-machine-id'; import { Notice, Plugin, requestUrl } from "obsidian";
import { Notice, Platform, Plugin, requestUrl } from "obsidian";
import { INFIO_BASE_URL } from "../constants"; import { INFIO_BASE_URL } from "../constants";
import { getDeviceId, getOperatingSystem } from "../utils/device-id";
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';
}
// API响应类型定义 // API响应类型定义
export type UserPlanResponse = { export type UserPlanResponse = {
@ -76,8 +56,8 @@ export const checkGeneral = async (
if (!apiKey) { if (!apiKey) {
throw new Error('API密钥不能为空'); throw new Error('API密钥不能为空');
} }
const deviceId = await machineId(); const deviceId = await getDeviceId();
const deviceName = getOperatingSystem(); const deviceName = getOperatingSystem();
if (!deviceId || !deviceName) { if (!deviceId || !deviceName) {
throw new Error('设备ID和设备名称不能为空'); throw new Error('设备ID和设备名称不能为空');
} }
@ -336,12 +316,12 @@ export const upgradeToProVersion = async (
console.log(`重载插件: ${plugin.manifest.id}`); console.log(`重载插件: ${plugin.manifest.id}`);
try { try {
// 禁用插件 // 禁用插件
// @ts-ignore // @ts-expect-error obsidian typings do not expose this internal API
await plugin.app.plugins.disablePlugin(plugin.manifest.id); await plugin.app.plugins.disablePlugin(plugin.manifest.id);
console.log(`插件已禁用: ${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); await plugin.app.plugins.enablePlugin(plugin.manifest.id);
console.log(`插件已重新启用: ${plugin.manifest.id}`); console.log(`插件已重新启用: ${plugin.manifest.id}`);

568
src/main.desktop.ts Normal file
View File

@ -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<DBManager> | null
ragEngineInitPromise: Promise<RAGEngine> | null
transEngineInitPromise: Promise<TransEngine> | null
mcpHubInitPromise: Promise<McpHub> | 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<void>
setSettings: (newSettings: InfioSettings) => Promise<void>
addSettingsListener: (listener: (newSettings: InfioSettings) => void) => () => void
openChatView: (openNewChat?: boolean) => Promise<void>
activateChatView: (chatProps?: ChatProps, openNewChat?: boolean) => Promise<void>
addSelectionToChat: (editor: Editor, view: MarkdownView) => Promise<void>
getDbManager: () => Promise<DBManager>
getMcpHub: () => Promise<McpHub | null>
getRAGEngine: () => Promise<RAGEngine>
getTransEngine: () => Promise<TransEngine>
getEmbeddingManager: () => EmbeddingManager | null
migrateToJsonStorage: () => Promise<void>
reloadChatView: () => Promise<void>
}
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<DBManager> {
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<McpHub | null> {
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<RAGEngine> {
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<TransEngine> {
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<void>
),
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
}

491
src/main.mobile.ts Normal file
View File

@ -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<CheckGeneralResponse>
*/
export const checkGeneral = async (
apiKey: string
): Promise<CheckGeneralResponse> => {
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<UserPlanResponse> => {
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<void> => {
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<void> => {
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<void> => {
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<UpgradeResult> => {
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<void> }
constructor(app: App, plugin: Plugin & { settings: InfioSettings; setSettings: (s: InfioSet·tings) => Promise<void> }) {
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<void>
setSettings: (s: InfioSettings) => Promise<void>
}
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
}

View File

@ -1,685 +1,26 @@
// @ts-nocheck // @ts-nocheck
import { EditorView } from '@codemirror/view' import { Platform, Plugin } from 'obsidian'
// 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'
export default class InfioPlugin extends Plugin { export default class InfioPlugin extends Plugin {
private metadataCacheUnloadFn: (() => void) | null = null
private activeLeafChangeUnloadFn: (() => void) | null = null
private dbManagerInitPromise: Promise<DBManager> | null = null
private ragEngineInitPromise: Promise<RAGEngine> | null = null
private transEngineInitPromise: Promise<TransEngine> | null = null
private mcpHubInitPromise: Promise<McpHub> | 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() { async onload() {
// load settings if (Platform.isMobile) {
await this.loadSettings() console.log('Infio Copilot: Mobile platform detected, skipping desktop-only features.')
const mod = await import('./main.mobile')
// migrate to json database await mod.loadMobile(this)
setTimeout(() => { } else {
void this.migrateToJsonStorage().then(() => { }) console.log('Infio Copilot: Desktop platform detected, loading desktop features.')
void onEnt('loaded') const mod = await import('./main.desktop')
}, 100) await mod.loadDesktop(this)
}
// 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<void>
),
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);
}
},
});
} }
onunload() { onunload() {
// Promise cleanup if (Platform.isMobile) {
this.dbManagerInitPromise = null void import('./main.mobile').then((m) => m.unloadMobile?.(this)).catch(() => {})
this.ragEngineInitPromise = null } else {
this.transEngineInitPromise = null void import('./main.desktop').then((m) => m.unloadDesktop?.(this)).catch(() => {})
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,
)
} }
} }
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<DBManager> {
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<McpHub | null> {
// 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<RAGEngine> {
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<TransEngine> {
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()
}
} }

View File

@ -10,7 +10,6 @@ import * as React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { t } from '../lang/helpers'; import { t } from '../lang/helpers';
import InfioPlugin from '../main';
import { InfioSettings } from '../types/settings'; import { InfioSettings } from '../types/settings';
import { findFilesMatchingPatterns } from '../utils/glob-utils'; import { findFilesMatchingPatterns } from '../utils/glob-utils';
@ -24,13 +23,18 @@ import PreprocessingSettings from './components/PreprocessingSettings';
import PrivacySettings from './components/PrivacySettings'; import PrivacySettings from './components/PrivacySettings';
import TriggerSettingsSection from './components/TriggerSettingsSection'; import TriggerSettingsSection from './components/TriggerSettingsSection';
type InfioPluginLike = Plugin & {
settings: InfioSettings;
setSettings: (s: InfioSettings) => Promise<void>;
}
export class InfioSettingTab extends PluginSettingTab { export class InfioSettingTab extends PluginSettingTab {
plugin: InfioPlugin; plugin: InfioPluginLike;
private autoCompleteContainer: HTMLElement | null = null; private autoCompleteContainer: HTMLElement | null = null;
private modelsContainer: HTMLElement | null = null; private modelsContainer: HTMLElement | null = null;
private pluginInfoContainer: HTMLElement | null = null; private pluginInfoContainer: HTMLElement | null = null;
constructor(app: App, plugin: InfioPlugin) { constructor(app: App, plugin: InfioPluginLike) {
super(app, plugin) super(app, plugin)
this.plugin = plugin this.plugin = plugin
} }

View File

@ -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 <mask/>.
Your answer can be either code, a single word, or multiple sentences.
If the <mask/> 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 <mask/>.
Your answer must be in the same language as the text directly surrounding the <mask/>.
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 <mask/>
`,
fewShotExamples: [
],
userMessageTemplate: "{{prefix}}<mask/>{{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<string, string> = { "[": "]", "{": "}", "(": ")" };
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<typeof InfioSettingsSchema>
export type FilesSearchSettings = z.infer<typeof FilesSearchSettingsSchema>
type Migration = {
fromVersion: number
toVersion: number
migrate: (data: Record<string, unknown>) => Record<string, unknown>
}
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<string, any>
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>)
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 })
}
}

84
src/utils/device-id.ts Normal file
View File

@ -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<string> {
// 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<string, unknown>).machineId === 'function'
) {
const id = await (mod as { machineId: () => Promise<string> }).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'
}

View File

@ -73,7 +73,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; 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 { .infio-chat-header-title {
@ -89,8 +90,8 @@
.infio-chat-current-workspace { .infio-chat-current-workspace {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-left: var(--size-4-2); margin-left: var(--size-2-2);
padding: var(--size-2-1) var(--size-4-1); padding: var(--size-2-1) var(--size-2-2);
background-color: var(--background-secondary); background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s); border-radius: var(--radius-s);
@ -102,11 +103,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
transition: all 0.2s ease; transition: all 0.2s ease;
min-height: 28px;
} }
.infio-chat-header-buttons { .infio-chat-header-buttons {
display: flex; display: flex;
gap: var(--size-2-1); gap: var(--size-1-1);
} }
.infio-chat-container { .infio-chat-container {
@ -118,12 +120,21 @@
.infio-stop-gen-btn { .infio-stop-gen-btn {
z-index: 1000; z-index: 1000;
position: absolute; position: absolute;
bottom: 160px; bottom: 120px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
align-items: center; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--size-2-1); gap: var(--size-2-1);
padding: 0 var(--size-4-3) var(--size-4-5) var(--size-4-3); padding: 0 var(--size-2-2) var(--size-4-3) var(--size-2-2);
margin: var(--size-4-2) calc(var(--size-4-3) * -1) 0; margin: var(--size-2-2) calc(var(--size-2-2) * -1) 0;
/* 确保内容不会超出容器 */ /* 确保内容不会超出容器 */
min-width: 0; min-width: 0;
@ -158,7 +169,7 @@
.infio-chat-messages-assistant { .infio-chat-messages-assistant {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--size-2-2); /* 防止助手消息超出容器 */
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
} }
@ -170,7 +181,8 @@
*/ */
.infio-starred-commands { .infio-starred-commands {
border: none !important; 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 { .infio-starred-commands-title {
@ -193,17 +205,18 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--size-2-1);
padding: 1px 8px; padding: var(--size-2-1) var(--size-2-3);
background-color: rgba(86, 72, 29, 0.3) !important; background-color: rgba(86, 72, 29, 0.3) !important;
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
border-radius: var(--radius-l); border-radius: var(--radius-l);
font-size: var(--font-ui-small); font-size: var(--font-ui-medium);
color: #f1c43f; color: #f1c43f;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
max-width: 200px; min-height: 40px;
max-width: none;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@ -581,16 +594,20 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 36px;
height: 26px; height: 36px;
padding: 0; padding: 0;
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
color: var(--text-muted); color: var(--text-muted);
border-radius: var(--radius-s);
transition: all 0.2s ease;
&:hover { &:hover,
&:active {
background-color: var(--background-modifier-hover); 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); border: var(--input-border-width) solid var(--background-modifier-border);
color: var(--text-normal); color: var(--text-normal);
font-family: inherit; font-family: inherit;
padding: calc(var(--size-2-2) + 1px); padding: var(--size-2-1);
font-size: var(--font-ui-medium); font-size: var(--font-ui-medium);
border-radius: var(--radius-s); border-radius: var(--radius-s);
outline: none; outline: none;
margin: 0 var(--size-2-2) var(--size-2-2) var(--size-2-2);
&:focus-within, &:focus-within,
&:focus, &:focus,
@ -635,18 +653,18 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
.infio-chat-user-input-files { .infio-chat-user-input-files {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--size-4-1); gap: var(--size-2-1);
flex-wrap: wrap; flex-wrap: wrap;
padding-bottom: var(--size-4-1); padding-bottom: var(--size-2-1);
} }
.infio-chat-user-input-controls { .infio-chat-user-input-controls {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: var(--size-4-1); gap: var(--size-2-1);
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: var(--size-4-4); height: 36px;
.infio-chat-user-input-controls__model-select-container { .infio-chat-user-input-controls__model-select-container {
display: flex; display: flex;
@ -658,7 +676,7 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
.infio-chat-user-input-controls__buttons { .infio-chat-user-input-controls__buttons {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
gap: var(--size-4-3); gap: var(--size-2-1);
align-items: center; 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 { .infio-chat-user-input-controls .infio-chat-user-input-submit-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-4-1); gap: var(--size-2-1);
font-size: var(--font-smallest); font-size: var(--font-ui-smaller);
color: var(--text-muted); color: var(--text-muted);
background-color: transparent; background-color: transparent;
border: none; border: none;
box-shadow: none; box-shadow: none;
padding: 0 var(--size-2-1); padding: var(--size-2-1) var(--size-2-2);
border-radius: var(--radius-s); border-radius: var(--radius-s);
height: var(--size-4-4); height: 32px;
min-width: 36px;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease-in-out; transition: all 0.2s ease;
&:hover { &:hover,
&:active {
color: var(--text-normal); color: var(--text-normal);
background-color: var(--background-modifier-hover);
} }
.infio-chat-user-input-submit-button-icons { .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 { .infio-chat-user-input-controls .infio-chat-user-input-vault-button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--size-4-1); gap: var(--size-2-1);
font-size: var(--font-smallest); font-size: var(--font-ui-smaller);
color: var(--text-muted); color: var(--text-muted);
background-color: var(--background-secondary-alt) !important; background-color: var(--background-secondary-alt) !important;
border: none; border: none;
box-shadow: none; box-shadow: none;
padding: 0 var(--size-2-1); padding: var(--size-2-1) var(--size-2-2);
border-radius: var(--radius-s); border-radius: var(--radius-s);
height: var(--size-4-4); height: 32px;
min-width: 36px;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease-in-out; transition: all 0.2s ease;
&:hover { &:hover,
&:active {
color: var(--text-normal); color: var(--text-normal);
background-color: var(--background-modifier-hover) !important;
} }
.infio-chat-user-input-vault-button-icons { .infio-chat-user-input-vault-button-icons {
@ -719,12 +743,13 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
background-color: var(--background-secondary); background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s); border-radius: var(--radius-s);
font-size: var(--font-smallest); font-size: var(--font-ui-smaller);
padding: var(--size-2-1) var(--size-4-1); padding: var(--size-2-1) var(--size-2-2);
gap: var(--size-2-1); gap: var(--size-2-1);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-height: 28px;
&.infio-chat-user-input-file-badge-focused { &.infio-chat-user-input-file-badge-focused {
border: 1px solid var(--interactive-accent); border: 1px solid var(--interactive-accent);
@ -791,23 +816,27 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
.infio-chat-edit-cancel-button { .infio-chat-edit-cancel-button {
position: absolute; position: absolute;
top: 8px; top: var(--size-2-1);
right: 8px; right: var(--size-2-1);
z-index: 10; z-index: 10;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text-on-accent-hover); background-color: transparent !important;
background-color: var(--interactive-accent-hover);
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;
color: var(--text-muted);
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
width: 24px !important; width: 44px !important;
height: 24px !important; height: 44px !important;
border-radius: var(--radius-m) !important;
transition: all 0.2s ease !important;
&:hover { &:hover,
background-color: var(--interactive-accent-hover) !important; &: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); gap: var(--size-4-1);
} }
/* ===========================================
代码块样式 - 统一管理所有代码块相关样式
=========================================== */
/* 基础代码块容器 */
.infio-chat-code-block { .infio-chat-code-block {
position: relative; position: relative;
border: 1px solid var(--background-modifier-border); border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s); border-radius: var(--radius-s);
/* 防止代码块内容溢出 */
overflow: hidden; overflow: hidden;
word-wrap: break-word; word-wrap: break-word;
} }
@ -1008,43 +1033,70 @@ input[type='text'].infio-chat-list-dropdown-item-title-input {
padding: 0; padding: 0;
} }
/* RawMarkdownBlock 专用代码块容器 */ /* 删除冲突的样式定义 */
/* RawMarkdownBlock 代码块容器样式 */
.infio-raw-markdown-code-block { .infio-raw-markdown-code-block {
position: relative; position: relative !important;
margin: 0; z-index: 100 !important; /* 提高z-index优先级 */
padding: 0; margin: 0 !important;
width: 100%; padding: 0 !important;
overflow: visible; /* 确保代码块不会被其他元素遮挡 */
clear: both; 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; overflow: visible;
} /* 添加背景色以确保可见性 */
background: transparent;
/* RawMarkdownBlock 内的 code 元素 */ /* 确保不被截断 */
.infio-raw-markdown-code-block code {
position: relative;
display: block;
width: 100%; 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 { .infio-chat-code-block-header {
display: none; display: none;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-size: var(--font-smallest); font-size: var(--font-smallest);
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.infio-chat-code-block:hover .infio-chat-code-block-header { .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; left: 0;
} }
/* 代码块头部文件名样式 */
.infio-chat-code-block-header-filename { .infio-chat-code-block-header-filename {
padding-left: var(--size-4-2); padding-left: var(--size-4-2);
font-size: var(--font-medium); font-size: var(--font-medium);
@ -1085,7 +1136,6 @@ input[type='text'].infio-chat-list-dropdown-item-title-input {
flex-shrink: 0; flex-shrink: 0;
} }
/* 代码块头部按钮样式 */
.infio-chat-code-block-header-button { .infio-chat-code-block-header-button {
display: flex; display: flex;
gap: var(--size-4-1); 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); 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; box-shadow: none;
border: 0; border: 0;
padding: 0 var(--size-4-2); 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); font-size: var(--font-medium);
height: 100%; height: 100%;
cursor: pointer; cursor: pointer;
}
.infio-chat-code-block.has-filename .infio-chat-code-block-header-button button:hover { &:hover {
background-color: var(--background-modifier-hover); background-color: var(--background-modifier-hover);
}
} }
.infio-chat-code-block-header-button button { .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); font-size: var(--font-ui-smaller);
} }
/* 代码块内容样式 */
.infio-chat-code-block-content {
margin: 0;
}
/* 特殊按钮样式 */
.infio-dataview-query-button { .infio-dataview-query-button {
color: #008000; color: #008000;
} }
/* =========================================== .infio-chat-code-block-content {
推理内容包装器样式 margin: 0;
=========================================== */
.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;
} }
/* Read File Block - Minimal styling for better integration */ /* 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 { .infio-chat-lexical-content-editable-root {
min-height: 62px; min-height: 36px;
max-height: 800px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: var(--size-2-1);
} }
.infio-chat-lexical-content-editable-root .mention { .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 { .infio-search-lexical-content-editable-root {
min-height: 36px; min-height: 40px;
max-height: 120px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: var(--size-2-1);
/* 确保编辑器不会影响外部滚动 */ /* 确保编辑器不会影响外部滚动 */
contain: size style; contain: size style;
} }
@ -1713,22 +1742,27 @@ input[type='text'].infio-chat-list-dropdown-item-title-input {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: end; justify-content: end;
gap: var(--size-2-1);
button { button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 20px; height: 44px;
width: 20px; width: 44px;
padding: 0; padding: 0;
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
color: var(--text-faint); color: var(--text-faint);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-m);
transition: all 0.2s ease;
&:hover { &:hover,
&:active {
background-color: var(--background-modifier-hover); 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%; 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 */ /* control pc and mobile view */
.desktop-only { .desktop-only {
@ -2686,20 +2763,21 @@ button.infio-chat-input-model-select {
box-shadow: none; box-shadow: none;
border: 1; border: 1;
padding: var(--size-2-1) var(--size-2-2); 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); font-weight: var(--font-medium);
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
height: var(--size-4-4); height: 32px;
max-width: 100%; max-width: 100%;
gap: var(--size-2-2); gap: var(--size-2-1);
border-radius: var(--radius-s); border-radius: var(--radius-s);
transition: all 0.15s ease-in-out; transition: all 0.2s ease;
&:hover { &:hover,
&:active {
color: var(--text-normal); color: var(--text-normal);
background-color: var(--background-modifier-hover); background-color: var(--background-modifier-hover);
} }
@ -2799,6 +2877,7 @@ button.infio-chat-input-model-select {
align-items: center; align-items: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: var(--size-4-3);
} }
.infio-chat-loading-container { .infio-chat-loading-container {
@ -2998,8 +3077,10 @@ button.infio-chat-input-model-select {
} }
.infio-commands-header-title { .infio-commands-header-title {
margin: 0; color: var(--text-normal);
font-size: 24px; font-size: 28px;
font-weight: 500;
margin: 0 0 var(--size-4-3) 0;
} }
.infio-commands-label { .infio-commands-label {
@ -3655,18 +3736,18 @@ button.infio-chat-input-model-select {
.infio-markdown-actions { .infio-markdown-actions {
position: absolute; position: absolute;
bottom: 1px; top: 8px;
right: 1px; right: 8px;
display: flex; display: flex;
gap: 4px; gap: 4px;
opacity: 0.7; opacity: 0.7;
visibility: visible; visibility: visible;
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
padding: 0px; padding: 0px;
z-index: 10; /* 降低z-index确保不会遮挡代码块 */ z-index: 50; /* 降低z-index确保不会遮挡代码块 */
/* 确保不会遮挡代码块 */ /* 确保不会遮挡代码块 */
pointer-events: none; pointer-events: none;
} }
.infio-markdown-actions button { .infio-markdown-actions button {
display: flex; display: flex;
align-items: center; align-items: center;
@ -3677,19 +3758,18 @@ button.infio-chat-input-model-select {
background-color: transparent; background-color: transparent;
border-color: transparent; border-color: transparent;
box-shadow: none; box-shadow: none;
color: var(--text-muted); color: var(--text-faint);
border-radius: var(--radius-s);
cursor: pointer; cursor: pointer;
/* 恢复按钮的点击事件 */ /* 恢复按钮的点击事件 */
pointer-events: auto; pointer-events: auto;
}
&:hover { .infio-markdown-actions button:hover {
background-color: var(--interactive-accent-hover); background-color: var(--background-modifier-hover);
} }
}
.infio-chat-message-actions-icon--copied { .infio-markdown-actions .infio-chat-message-actions-icon--copied {
color: var(--text-muted); color: var(--text-muted);
} }
.infio-markdown-container-with-actions:hover .infio-markdown-actions { .infio-markdown-container-with-actions:hover .infio-markdown-actions {
@ -3957,136 +4037,3 @@ button.infio-chat-input-model-select {
transform: translateY(0); 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;
}