From 31bc19c37aea203ecd0d040b0d7f957896dd53d8 Mon Sep 17 00:00:00 2001 From: miaobuao Date: Mon, 30 Mar 2026 11:56:52 +0800 Subject: [PATCH 01/18] feat: DiffMatchPatchOrSkip strategy --- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh.ts | 1 + src/index.ts | 14 +++-- src/settings/common.ts | 4 ++ src/sync/tasks/conflict-resolve.task.ts | 81 +++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4cb7ff90..82214c21 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -64,6 +64,7 @@ export default { diffMatchPatch: 'Smart merge (recommended)', latestTimestamp: 'Use latest version', skip: 'Skip conflicts', + diffMatchPatchOrSkip: 'Smart merge, skip if unresolvable', }, confirmBeforeSync: { name: 'Confirm before sync', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 3520a035..5cfc495c 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -53,6 +53,7 @@ export default { diffMatchPatch: '智能合并(推荐)', latestTimestamp: '使用最新版本', skip: '跳过冲突', + diffMatchPatchOrSkip: '智能合并,无法合并时跳过', }, loginMode: { name: '登录方式', diff --git a/src/index.ts b/src/index.ts index 8926ceac..cefa72a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,13 +12,13 @@ import { SyncRibbonManager } from './components/SyncRibbonManager' import { emitCancelSync } from './events' import { emitSsoReceive } from './events/sso-receive' import i18n from './i18n' -import ScheduledSyncService from './services/scheduled-sync.service' import CommandService from './services/command.service' import EventsService from './services/events.service' import I18nService from './services/i18n.service' import LoggerService from './services/logger.service' import { ProgressService } from './services/progress.service' import RealtimeSyncService from './services/realtime-sync.service' +import ScheduledSyncService from './services/scheduled-sync.service' import { StatusService } from './services/status.service' import SyncExecutorService from './services/sync-executor.service' import { WebDAVService } from './services/webdav.service' @@ -35,7 +35,7 @@ import { stdRemotePath } from './utils/std-remote-path' export default class NutstorePlugin extends Plugin { public isSyncing: boolean = false - public settings: NutstoreSettings + public settings!: NutstoreSettings public commandService = new CommandService(this) public eventsService = new EventsService(this) @@ -50,7 +50,10 @@ export default class NutstorePlugin extends Plugin { this, this.syncExecutorService, ) - public scheduledSyncService = new ScheduledSyncService(this, this.syncExecutorService) + public scheduledSyncService = new ScheduledSyncService( + this, + this.syncExecutorService, + ) async onload() { await this.loadSettings() @@ -154,7 +157,10 @@ export default class NutstorePlugin extends Plugin { isAccountConfigured(): boolean { if (this.settings.loginMode === 'sso') { // SSO 模式:检查是否有 OAuth 响应数据 - return !!this.settings.oauthResponseText && this.settings.oauthResponseText.trim() !== '' + return ( + !!this.settings.oauthResponseText && + this.settings.oauthResponseText.trim() !== '' + ) } else { // 手动模式:检查账号和凭证是否都已填写 return ( diff --git a/src/settings/common.ts b/src/settings/common.ts index 0203515b..5b4cc385 100644 --- a/src/settings/common.ts +++ b/src/settings/common.ts @@ -84,6 +84,10 @@ export default class CommonSettings extends BaseSettings { ConflictStrategy.Skip, i18n.t('settings.conflictStrategy.skip'), ) + .addOption( + ConflictStrategy.DiffMatchPatchOrSkip, + i18n.t('settings.conflictStrategy.diffMatchPatchOrSkip'), + ) .setValue(this.plugin.settings.conflictStrategy) .onChange(async (value: ConflictStrategy) => { this.plugin.settings.conflictStrategy = value diff --git a/src/sync/tasks/conflict-resolve.task.ts b/src/sync/tasks/conflict-resolve.task.ts index e9764d08..a3d67e3e 100644 --- a/src/sync/tasks/conflict-resolve.task.ts +++ b/src/sync/tasks/conflict-resolve.task.ts @@ -20,6 +20,7 @@ export enum ConflictStrategy { DiffMatchPatch = 'diff-match-patch', LatestTimeStamp = 'latest-timestamp', Skip = 'skip', + DiffMatchPatchOrSkip = 'diff-match-patch-or-skip', } export default class ConflictResolveTask extends BaseTask { @@ -70,6 +71,8 @@ export default class ConflictResolveTask extends BaseTask { // Skip conflict resolution - keep files as they are // Don't update record to preserve conflict state for next sync return { success: true, skipRecord: true } as const + case ConflictStrategy.DiffMatchPatchOrSkip: + return await this.execIntelligentMergeOrSkip() } } catch (e) { logger.error(this, e) @@ -142,6 +145,84 @@ export default class ConflictResolveTask extends BaseTask { } } + async execIntelligentMergeOrSkip() { + try { + const file = this.vault.getFileByPath(this.localPath) + if (!file) { + throw new Error('cannot find file in local fs: ' + this.localPath) + } + const localBuffer = await this.vault.readBinary(file) + const remoteBuffer = (await this.webdav.getFileContents(this.remotePath, { + format: 'binary', + details: false, + })) as BufferLike + + if (isEqual(localBuffer, remoteBuffer)) { + return { success: true } as const + } + + const { record } = this.options + let baseBlob: Blob | null = null + const baseKey = record?.base?.key + if (baseKey) { + baseBlob = await blobStore.get(baseKey) + } + + const localIsMergeable = isMergeablePath(file.path) + const remoteIsMergeable = isMergeablePath(this.remotePath) + + if (!(localIsMergeable && remoteIsMergeable)) { + throw new Error(i18n.t('sync.error.mergeNotSupported')) + } + + const localText = await new Blob([new Uint8Array(localBuffer)]).text() + const remoteText = await new Blob([new Uint8Array(remoteBuffer)]).text() + const baseText = (await baseBlob?.text()) ?? localText + + const mergeResult = await resolveByIntelligentMerge({ + localContentText: localText, + remoteContentText: remoteText, + baseContentText: baseText, + }) + + if (!mergeResult.success) { + throw new Error(i18n.t('sync.error.failedToAutoMerge')) + } + + if (mergeResult.isIdentical) { + return { success: true } as const + } + + const mergedText = mergeResult.mergedText! + + if (mergedText === remoteText) { + if (mergedText !== localText) { + await this.vault.modify(file, mergedText) + } + return { success: true } as const + } + + const putResult = await this.webdav.putFileContents( + this.remotePath, + mergedText, + { overwrite: true }, + ) + + if (!putResult) { + throw new Error(i18n.t('sync.error.failedToUploadMerged')) + } + + if (localText !== mergedText) { + await this.vault.modify(file, mergedText) + } + + return { success: true } as const + } catch (e) { + logger.error(this, e) + return { success: false, error: toTaskError(e, this) } + } + } + async execIntelligentMerge() { try { const file = this.vault.getFileByPath(this.localPath) From 93aa83ba2014300aa4a17b53ff4509c5dc81d0b7 Mon Sep 17 00:00:00 2001 From: miaobuao Date: Wed, 8 Apr 2026 15:37:54 +0800 Subject: [PATCH 02/18] refactor: use adapter api --- src/settings/log.ts | 4 +- src/sync/decision/two-way.decider.ts | 6 +- src/sync/tasks/adapter-tasks.test.ts | 203 ++++++++++++++++++++++++ src/sync/tasks/conflict-resolve.task.ts | 34 ++-- src/sync/tasks/pull.task.ts | 9 +- src/sync/tasks/push.task.ts | 6 +- src/sync/utils/update-records.test.ts | 149 +++++++++++++++++ src/sync/utils/update-records.ts | 9 +- src/utils/mkdirs-vault.ts | 10 +- src/utils/stat-vault-item.ts | 16 +- src/utils/traverse-local-vault.ts | 37 ++--- src/utils/vault-adapter-utils.test.ts | 192 ++++++++++++++++++++++ 12 files changed, 607 insertions(+), 68 deletions(-) create mode 100644 src/sync/tasks/adapter-tasks.test.ts create mode 100644 src/sync/utils/update-records.test.ts create mode 100644 src/utils/vault-adapter-utils.test.ts diff --git a/src/settings/log.ts b/src/settings/log.ts index cd2a0a6f..2d20fd56 100644 --- a/src/settings/log.ts +++ b/src/settings/log.ts @@ -48,9 +48,9 @@ export default class LogSettings extends BaseSettings { const content = `# Nutstore Plugin Logs\n\nGenerated at: ${new Date().toLocaleString()}\n\n---\n\n${this.logs}` // 确保目录存在 - const folderExists = await this.app.vault.getFolderByPath(dirPath) + const folderExists = await this.app.vault.adapter.exists(dirPath) if (!folderExists) { - await this.app.vault.createFolder(dirPath) + await this.app.vault.adapter.mkdir(dirPath) } const file = await this.app.vault.create(filePath, content) diff --git a/src/sync/decision/two-way.decider.ts b/src/sync/decision/two-way.decider.ts index 473f6ddf..d4ce0dc5 100644 --- a/src/sync/decision/two-way.decider.ts +++ b/src/sync/decision/two-way.decider.ts @@ -70,9 +70,9 @@ export default class TwoWaySyncDecider extends BaseSyncDecider { filePath: string, baseContent: ArrayBuffer, ): Promise => { - const file = this.vault.getFileByPath(filePath) - if (!file) return false - const currentContent = await this.vault.readBinary(file) + const exists = await this.vault.adapter.exists(filePath) + if (!exists) return false + const currentContent = await this.vault.adapter.readBinary(filePath) return isEqual(baseContent, currentContent) } const getBaseContent = async (key: string): Promise => { diff --git a/src/sync/tasks/adapter-tasks.test.ts b/src/sync/tasks/adapter-tasks.test.ts new file mode 100644 index 00000000..a9a55989 --- /dev/null +++ b/src/sync/tasks/adapter-tasks.test.ts @@ -0,0 +1,203 @@ +import { Buffer } from 'buffer' +import type { Vault } from 'obsidian' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebDAVClient } from 'webdav' + +vi.mock('~/utils/get-task-name', () => ({ + default: () => 'task', +})) + +import ConflictResolveTask, { + ConflictStrategy, +} from './conflict-resolve.task' +import PullTask from './pull.task' +import PushTask from './push.task' + +const syncRecordStub = {} as never + +function createVault() { + return { + adapter: { + exists: vi.fn(), + readBinary: vi.fn(), + writeBinary: vi.fn(), + write: vi.fn(), + mkdir: vi.fn(), + }, + } as unknown as Vault & { + adapter: { + exists: ReturnType + readBinary: ReturnType + writeBinary: ReturnType + write: ReturnType + mkdir: ReturnType + } + } +} + +function createWebdav() { + return { + getFileContents: vi.fn(), + putFileContents: vi.fn(), + } as unknown as WebDAVClient & { + getFileContents: ReturnType + putFileContents: ReturnType + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('PullTask', () => { + it('writes downloaded content through adapter.writeBinary after mkdirs', async () => { + const vault = createVault() + vault.adapter.exists.mockResolvedValue(false) + const webdav = createWebdav() + const remoteBuffer = Uint8Array.from([1, 2, 3]).buffer + webdav.getFileContents.mockResolvedValue(remoteBuffer) + + const task = new PullTask({ + vault, + webdav, + remoteBaseDir: '/remote', + remotePath: 'folder/file.bin', + localPath: 'folder/file.bin', + syncRecord: syncRecordStub, + remoteSize: 3, + }) + + await expect(task.exec()).resolves.toEqual({ success: true }) + expect(vault.adapter.mkdir).toHaveBeenCalledWith('folder') + expect(vault.adapter.writeBinary).toHaveBeenCalledWith( + 'folder/file.bin', + remoteBuffer, + ) + }) +}) + +describe('PushTask', () => { + it('reads local content through adapter before uploading', async () => { + const vault = createVault() + const localBuffer = Uint8Array.from([9, 8]).buffer + vault.adapter.exists.mockResolvedValue(true) + vault.adapter.readBinary.mockResolvedValue(localBuffer) + const webdav = createWebdav() + webdav.putFileContents.mockResolvedValue(true) + + const task = new PushTask({ + vault, + webdav, + remoteBaseDir: '/remote', + remotePath: 'file.bin', + localPath: 'file.bin', + syncRecord: syncRecordStub, + }) + + await expect(task.exec()).resolves.toEqual({ success: true }) + expect(vault.adapter.readBinary).toHaveBeenCalledWith('file.bin') + expect(webdav.putFileContents).toHaveBeenCalledWith('/remote/file.bin', localBuffer, { + overwrite: true, + }) + }) +}) + +describe('ConflictResolveTask', () => { + it('uses adapter.writeBinary when latest timestamp chooses remote content', async () => { + const vault = createVault() + vault.adapter.exists.mockResolvedValue(true) + vault.adapter.readBinary.mockResolvedValue(Buffer.from('local').buffer) + const webdav = createWebdav() + const remoteContent = Buffer.from('remote') + webdav.getFileContents.mockResolvedValue(remoteContent) + + const task = new ConflictResolveTask({ + vault, + webdav, + remoteBaseDir: '/remote', + remotePath: 'note.md', + localPath: 'note.md', + syncRecord: syncRecordStub, + strategy: ConflictStrategy.LatestTimeStamp, + useGitStyle: false, + localStat: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 1, + size: 5, + }, + remoteStat: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 2, + size: 6, + }, + }) + + await expect(task.exec()).resolves.toEqual({ success: true }) + expect(vault.adapter.writeBinary).toHaveBeenCalledTimes(1) + expect(vault.adapter.writeBinary.mock.calls[0]?.[0]).toBe('note.md') + }) + + it('uses adapter.write for merged text updates', async () => { + const vault = createVault() + vault.adapter.exists.mockResolvedValue(true) + vault.adapter.readBinary.mockResolvedValue(Buffer.from('hello world').buffer) + const webdav = createWebdav() + webdav.getFileContents.mockResolvedValue(Buffer.from('hello brave world')) + webdav.putFileContents.mockResolvedValue(true) + + const task = new ConflictResolveTask({ + vault, + webdav, + remoteBaseDir: '/remote', + remotePath: 'note.md', + localPath: 'note.md', + syncRecord: syncRecordStub, + strategy: ConflictStrategy.DiffMatchPatch, + useGitStyle: false, + record: { + local: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 1, + size: 11, + }, + remote: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 2, + size: 17, + }, + }, + localStat: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 1, + size: 11, + }, + remoteStat: { + path: 'note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 2, + size: 17, + }, + }) + + await expect(task.exec()).resolves.toEqual({ success: true }) + expect(vault.adapter.write).toHaveBeenCalledTimes(1) + expect(vault.adapter.write.mock.calls[0]?.[0]).toBe('note.md') + }) +}) diff --git a/src/sync/tasks/conflict-resolve.task.ts b/src/sync/tasks/conflict-resolve.task.ts index a3d67e3e..17df93e7 100644 --- a/src/sync/tasks/conflict-resolve.task.ts +++ b/src/sync/tasks/conflict-resolve.task.ts @@ -94,8 +94,8 @@ export default class ConflictResolveTask extends BaseTask { return { success: true } as const } - const file = this.vault.getFileByPath(this.localPath) - if (!file) { + const exists = await this.vault.adapter.exists(this.localPath) + if (!exists) { return { success: false, error: toTaskError( @@ -104,7 +104,7 @@ export default class ConflictResolveTask extends BaseTask { ), } } - const localContent = await this.vault.readBinary(file) + const localContent = await this.vault.adapter.readBinary(this.localPath) const remoteContent = (await this.webdav.getFileContents( this.remotePath, { @@ -126,7 +126,7 @@ export default class ConflictResolveTask extends BaseTask { result.content instanceof ArrayBuffer ? result.content : new Uint8Array(result.content).buffer - await this.vault.modifyBinary(file, arrayBuffer) + await this.vault.adapter.writeBinary(this.localPath, arrayBuffer) break case LatestTimestampResolution.UseLocal: await this.webdav.putFileContents(this.remotePath, result.content, { @@ -147,11 +147,11 @@ export default class ConflictResolveTask extends BaseTask { async execIntelligentMergeOrSkip() { try { - const file = this.vault.getFileByPath(this.localPath) - if (!file) { + const exists = await this.vault.adapter.exists(this.localPath) + if (!exists) { throw new Error('cannot find file in local fs: ' + this.localPath) } - const localBuffer = await this.vault.readBinary(file) + const localBuffer = await this.vault.adapter.readBinary(this.localPath) const remoteBuffer = (await this.webdav.getFileContents(this.remotePath, { format: 'binary', details: false, @@ -168,7 +168,7 @@ export default class ConflictResolveTask extends BaseTask { baseBlob = await blobStore.get(baseKey) } - const localIsMergeable = isMergeablePath(file.path) + const localIsMergeable = isMergeablePath(this.localPath) const remoteIsMergeable = isMergeablePath(this.remotePath) if (!(localIsMergeable && remoteIsMergeable)) { @@ -197,7 +197,7 @@ export default class ConflictResolveTask extends BaseTask { if (mergedText === remoteText) { if (mergedText !== localText) { - await this.vault.modify(file, mergedText) + await this.vault.adapter.write(this.localPath, mergedText) } return { success: true } as const } @@ -213,7 +213,7 @@ export default class ConflictResolveTask extends BaseTask { } if (localText !== mergedText) { - await this.vault.modify(file, mergedText) + await this.vault.adapter.write(this.localPath, mergedText) } return { success: true } as const @@ -225,11 +225,11 @@ export default class ConflictResolveTask extends BaseTask { async execIntelligentMerge() { try { - const file = this.vault.getFileByPath(this.localPath) - if (!file) { + const exists = await this.vault.adapter.exists(this.localPath) + if (!exists) { throw new Error('cannot find file in local fs: ' + this.localPath) } - const localBuffer = await this.vault.readBinary(file) + const localBuffer = await this.vault.adapter.readBinary(this.localPath) const remoteBuffer = (await this.webdav.getFileContents(this.remotePath, { format: 'binary', details: false, @@ -246,7 +246,7 @@ export default class ConflictResolveTask extends BaseTask { baseBlob = await blobStore.get(baseKey) } - const localIsMergeable = isMergeablePath(file.path) + const localIsMergeable = isMergeablePath(this.localPath) const remoteIsMergeable = isMergeablePath(this.remotePath) if (!(localIsMergeable && remoteIsMergeable)) { @@ -280,7 +280,7 @@ export default class ConflictResolveTask extends BaseTask { ) if (putResult) { - await this.vault.modify(file, mergedDmpText) + await this.vault.adapter.write(this.localPath, mergedDmpText) return { success: true } as const } else { throw new Error(i18n.t('sync.error.failedToUploadMerged')) @@ -298,7 +298,7 @@ export default class ConflictResolveTask extends BaseTask { // If mergedText is the same as remoteText, we only need to update localText if it's different. if (mergedText === remoteText) { if (mergedText !== localText) { - await this.vault.modify(file, mergedText) + await this.vault.adapter.write(this.localPath, mergedText) } return { success: true } as const } @@ -315,7 +315,7 @@ export default class ConflictResolveTask extends BaseTask { } if (localText !== mergedText) { - await this.vault.modify(file, mergedText) + await this.vault.adapter.write(this.localPath, mergedText) } return { success: true } as const diff --git a/src/sync/tasks/pull.task.ts b/src/sync/tasks/pull.task.ts index fdfff9c4..3f2f3307 100644 --- a/src/sync/tasks/pull.task.ts +++ b/src/sync/tasks/pull.task.ts @@ -18,7 +18,6 @@ export default class PullTask extends BaseTask { } async exec() { - const fileExists = await this.vault.getFileByPath(this.localPath) try { const file = (await this.webdav.getFileContents(this.remotePath, { format: 'binary', @@ -28,12 +27,8 @@ export default class PullTask extends BaseTask { if (arrayBuffer.byteLength !== this.remoteSize) { throw new Error('Remote Size Not Match!') } - if (fileExists) { - await this.vault.modifyBinary(fileExists, arrayBuffer) - } else { - await mkdirsVault(this.vault, dirname(this.localPath)) - await this.vault.createBinary(this.localPath, arrayBuffer) - } + await mkdirsVault(this.vault, dirname(this.localPath)) + await this.vault.adapter.writeBinary(this.localPath, arrayBuffer) return { success: true } as const } catch (e) { logger.error(this, e) diff --git a/src/sync/tasks/push.task.ts b/src/sync/tasks/push.task.ts index 796f015b..30699a60 100644 --- a/src/sync/tasks/push.task.ts +++ b/src/sync/tasks/push.task.ts @@ -4,12 +4,12 @@ import { BaseTask, toTaskError } from './task.interface' export default class PushTask extends BaseTask { async exec() { try { - const file = this.vault.getFileByPath(this.localPath) - if (!file) { + const exists = await this.vault.adapter.exists(this.localPath) + if (!exists) { throw new Error('cannot find file in local fs: ' + this.localPath) } - const content = await this.vault.readBinary(file) + const content = await this.vault.adapter.readBinary(this.localPath) const res = await this.webdav.putFileContents(this.remotePath, content, { overwrite: true, }) diff --git a/src/sync/utils/update-records.test.ts b/src/sync/utils/update-records.test.ts new file mode 100644 index 00000000..75d1b651 --- /dev/null +++ b/src/sync/utils/update-records.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Vault } from 'obsidian' + +vi.mock('~/utils/get-task-name', () => ({ + default: () => 'task', +})) + +import { updateMtimeInRecord } from './update-records' + +const { + records, + setRecords, + getRecords, + walk, + blobStoreStore, +} = vi.hoisted(() => { + const records = new Map() + return { + records, + setRecords: vi.fn(async () => undefined), + getRecords: vi.fn(async () => records), + walk: vi.fn(async () => [ + { + stat: { + path: 'folder/file.md', + basename: 'file.md', + isDir: false, + isDeleted: false, + mtime: 20, + size: 10, + }, + ignored: false, + }, + ]), + blobStoreStore: vi.fn(async () => ({ key: 'blob-key', value: undefined })), + } +}) + +vi.mock('~/storage/sync-record', () => { + return { + SyncRecord: vi.fn().mockImplementation(() => ({ + getRecords, + setRecords, + })), + } +}) + +vi.mock('~/fs/nutstore', () => { + return { + NutstoreFileSystem: vi.fn().mockImplementation(() => ({ + walk, + })), + } +}) + +vi.mock('~/storage/blob', () => { + return { + blobStore: { + store: blobStoreStore, + }, + } +}) + +vi.mock('~/events', () => { + return { + emitSyncUpdateMtimeProgress: vi.fn(), + } +}) + +vi.mock('~/storage', () => { + return { + syncRecordKV: {}, + } +}) + +describe('updateMtimeInRecord', () => { + it('uses adapter stat and readBinary to persist local metadata immediately after writes', async () => { + records.clear() + setRecords.mockClear() + getRecords.mockClear() + walk.mockClear() + blobStoreStore.mockClear() + + const vault = { + getName: vi.fn(() => 'vault-name'), + adapter: { + stat: vi.fn(async (path: string) => { + if (path === 'folder/file.md') { + return { + type: 'file', + mtime: 10, + size: 10, + } + } + return null + }), + readBinary: vi.fn(async () => Uint8Array.from([1, 2, 3]).buffer), + }, + } as unknown as Vault & { + adapter: { + stat: ReturnType + readBinary: ReturnType + } + getName: ReturnType + } + + const task = { + localPath: 'folder/file.md', + toJSON: () => ({ localPath: 'folder/file.md' }), + } + + await updateMtimeInRecord( + { + getToken: vi.fn(async () => 'token'), + } as never, + vault, + '/remote', + [task as never], + [{ success: true }], + 10, + ) + + expect(vault.adapter.stat).toHaveBeenCalledWith('folder/file.md') + expect(vault.adapter.readBinary).toHaveBeenCalledWith('folder/file.md') + expect(blobStoreStore).toHaveBeenCalledTimes(1) + expect(records.get('folder/file.md')).toEqual({ + local: { + path: 'folder/file.md', + basename: 'file.md', + isDir: false, + isDeleted: false, + mtime: 10, + size: 10, + }, + remote: { + path: 'folder/file.md', + basename: 'file.md', + isDir: false, + isDeleted: false, + mtime: 20, + size: 10, + }, + base: { + key: 'blob-key', + }, + }) + expect(setRecords).toHaveBeenCalled() + }) +}) diff --git a/src/sync/utils/update-records.ts b/src/sync/utils/update-records.ts index 061cd4c6..ec3824bb 100644 --- a/src/sync/utils/update-records.ts +++ b/src/sync/utils/update-records.ts @@ -111,13 +111,8 @@ export async function updateMtimeInRecord( let base: { key: string } | undefined let baseKey: string | undefined if (!local.isDir) { - const file = vault.getFileByPath(localPath) - if (!file) { - return - } - - const buffer = await vault.readBinary(file) - const isMergeable = isMergeablePath(file.path) + const buffer = await vault.adapter.readBinary(localPath) + const isMergeable = isMergeablePath(localPath) if (!isMergeable) { baseKey = undefined } else { diff --git a/src/utils/mkdirs-vault.ts b/src/utils/mkdirs-vault.ts index 63b9d4c6..a7670487 100644 --- a/src/utils/mkdirs-vault.ts +++ b/src/utils/mkdirs-vault.ts @@ -1,4 +1,3 @@ -import { isNil } from 'lodash-es' import { Vault } from 'obsidian' import { dirname, normalize } from 'path-browserify' @@ -8,14 +7,14 @@ export async function mkdirsVault(vault: Vault, path: string) { if (currentPath === '/' || currentPath === '.') { return } - if (vault.getAbstractFileByPath(currentPath)) { + if (await vault.adapter.exists(currentPath)) { return } while ( currentPath !== '' && currentPath !== '/' && currentPath !== '.' && - isNil(vault.getAbstractFileByPath(currentPath)) + !(await vault.adapter.exists(currentPath)) ) { stack.push(currentPath) currentPath = dirname(currentPath) @@ -25,6 +24,9 @@ export async function mkdirsVault(vault: Vault, path: string) { if (!pop) { continue } - await vault.createFolder(pop) + if (await vault.adapter.exists(pop)) { + continue + } + await vault.adapter.mkdir(pop) } } diff --git a/src/utils/stat-vault-item.ts b/src/utils/stat-vault-item.ts index 4f5fee02..c299bc38 100644 --- a/src/utils/stat-vault-item.ts +++ b/src/utils/stat-vault-item.ts @@ -1,4 +1,4 @@ -import { normalizePath, TFile, TFolder, Vault } from 'obsidian' +import { normalizePath, Vault } from 'obsidian' import { basename } from 'path-browserify' import { StatModel } from '~/model/stat.model' @@ -7,25 +7,27 @@ export async function statVaultItem( path: string, ): Promise { path = normalizePath(path) - const file = vault.getAbstractFileByPath(path) - if (!file) { + const stat = await vault.adapter.stat(path) + if (!stat) { return undefined } - if (file instanceof TFolder) { + if (stat.type === 'folder') { return { path, basename: basename(path), isDir: true, isDeleted: false, + mtime: stat.mtime, } - } else if (file instanceof TFile) { + } + if (stat.type === 'file') { return { path, basename: basename(path), isDir: false, isDeleted: false, - mtime: file.stat.mtime, - size: file.stat.size, + mtime: stat.mtime, + size: stat.size, } } } diff --git a/src/utils/traverse-local-vault.ts b/src/utils/traverse-local-vault.ts index f8d3793d..7528442c 100644 --- a/src/utils/traverse-local-vault.ts +++ b/src/utils/traverse-local-vault.ts @@ -1,6 +1,4 @@ -import { isNil, partial } from 'lodash-es' -import { normalizePath, TFolder, Vault } from 'obsidian' -import { isNotNil } from 'ramda' +import { normalizePath, Vault } from 'obsidian' import { StatModel } from '~/model/stat.model' import GlobMatch from './glob-match' import { statVaultItem } from './stat-vault-item' @@ -22,25 +20,28 @@ export async function traverseLocalVault(vault: Vault, from: string) { } while (q.length > 0) { - const from = q.shift() - if (isNil(from)) { + const current = q.shift() + if (current === undefined) { continue } - const folder = vault.getAbstractFileByPath(normalizePath(from)) - if (!folder || !(folder instanceof TFolder)) { + const folderPath = normalizePath(current) + const folderStat = await vault.adapter.stat(folderPath) + if (!folderStat || folderStat.type !== 'folder') { continue } - const files = folder.children - .filter((f) => !(f instanceof TFolder)) - .map((f) => f.path) - let folders = folder.children - .filter((f) => f instanceof TFolder) - .map((f) => f.path) - folders = folders.filter(folderFilter) - q.push(...folders) - const contents = await Promise.all( - [...files, ...folders].map(partial(statVaultItem, vault)), - ).then((arr) => arr.filter(isNotNil)) + const { files, folders } = await vault.adapter.list(folderPath) + const normalizedFiles = files.map((path) => normalizePath(path)) + const normalizedFolders = folders + .map((path) => normalizePath(path)) + .filter(folderFilter) + q.push(...normalizedFolders) + const contents = ( + await Promise.all( + [...normalizedFiles, ...normalizedFolders].map((path) => + statVaultItem(vault, path), + ), + ) + ).filter((item): item is StatModel => item !== undefined) res.push(...contents) } return res diff --git a/src/utils/vault-adapter-utils.test.ts b/src/utils/vault-adapter-utils.test.ts new file mode 100644 index 00000000..6ead44a7 --- /dev/null +++ b/src/utils/vault-adapter-utils.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Vault } from 'obsidian' +import { mkdirsVault } from './mkdirs-vault' +import { statVaultItem } from './stat-vault-item' +import { traverseLocalVault } from './traverse-local-vault' + +type AdapterMock = { + stat: ReturnType + exists: ReturnType + list: ReturnType + mkdir: ReturnType +} + +function createVault(adapterOverrides: Partial = {}) { + const adapter: AdapterMock = { + stat: vi.fn(), + exists: vi.fn(), + list: vi.fn(), + mkdir: vi.fn(), + ...adapterOverrides, + } + + return { + adapter, + configDir: '.obsidian', + } as unknown as Vault & { adapter: AdapterMock; configDir: string } +} + +describe('statVaultItem', () => { + it('reads file metadata from adapter.stat', async () => { + const vault = createVault({ + stat: vi.fn().mockResolvedValue({ + type: 'file', + mtime: 123, + size: 456, + }), + }) + + await expect(statVaultItem(vault, 'folder/note.md')).resolves.toEqual({ + path: 'folder/note.md', + basename: 'note.md', + isDir: false, + isDeleted: false, + mtime: 123, + size: 456, + }) + }) + + it('returns directory metadata from adapter.stat', async () => { + const vault = createVault({ + stat: vi.fn().mockResolvedValue({ + type: 'folder', + mtime: 99, + size: 0, + }), + }) + + await expect(statVaultItem(vault, 'folder')).resolves.toEqual({ + path: 'folder', + basename: 'folder', + isDir: true, + isDeleted: false, + mtime: 99, + }) + }) + + it('returns undefined when the path is missing', async () => { + const vault = createVault({ + stat: vi.fn().mockResolvedValue(null), + }) + + await expect(statVaultItem(vault, 'missing.md')).resolves.toBeUndefined() + }) +}) + +describe('mkdirsVault', () => { + it('creates missing parent directories from top to bottom', async () => { + const existing = new Set() + const mkdir = vi.fn(async (path: string) => { + existing.add(path) + }) + const vault = createVault({ + exists: vi.fn(async (path: string) => existing.has(path)), + mkdir, + }) + + await mkdirsVault(vault, 'a/b/c') + + expect(mkdir.mock.calls.map(([path]) => path)).toEqual(['a', 'a/b', 'a/b/c']) + }) + + it('skips work for root-like paths and existing directories', async () => { + const mkdir = vi.fn() + const vault = createVault({ + exists: vi.fn(async (path: string) => path === 'exists'), + mkdir, + }) + + await mkdirsVault(vault, '.') + await mkdirsVault(vault, '/') + await mkdirsVault(vault, 'exists') + + expect(mkdir).not.toHaveBeenCalled() + }) +}) + +describe('traverseLocalVault', () => { + it('walks adapter.list recursively and ignores config node_modules', async () => { + const vault = createVault({ + stat: vi.fn(async (path: string) => { + const folders = new Set([ + '', + 'docs', + '.obsidian', + '.obsidian/plugins', + '.obsidian/plugins/test', + '.obsidian/plugins/test/node_modules', + ]) + if (folders.has(path)) { + return { type: 'folder', mtime: 1, size: 0 } + } + if (path === 'readme.md' || path === 'docs/file.md') { + return { type: 'file', mtime: 2, size: 3 } + } + if (path === '.obsidian/plugins/test/node_modules/dep.js') { + return { type: 'file', mtime: 4, size: 5 } + } + return null + }), + list: vi.fn(async (path: string) => { + if (path === '') { + return { + files: ['readme.md'], + folders: ['docs', '.obsidian'], + } + } + if (path === 'docs') { + return { + files: ['docs/file.md'], + folders: [], + } + } + if (path === '.obsidian') { + return { + files: [], + folders: ['.obsidian/plugins'], + } + } + if (path === '.obsidian/plugins') { + return { + files: [], + folders: ['.obsidian/plugins/test'], + } + } + if (path === '.obsidian/plugins/test') { + return { + files: [], + folders: ['.obsidian/plugins/test/node_modules'], + } + } + if (path === '.obsidian/plugins/test/node_modules') { + return { + files: ['.obsidian/plugins/test/node_modules/dep.js'], + folders: [], + } + } + return { files: [], folders: [] } + }), + }) + + const results = await traverseLocalVault(vault, '') + + expect(results.map((item) => item.path)).toEqual([ + 'readme.md', + 'docs', + '.obsidian', + 'docs/file.md', + '.obsidian/plugins', + '.obsidian/plugins/test', + ]) + }) + + it('returns an empty array when the start path is not a folder', async () => { + const vault = createVault({ + stat: vi.fn().mockResolvedValue(null), + list: vi.fn(), + }) + + await expect(traverseLocalVault(vault, 'missing')).resolves.toEqual([]) + expect(vault.adapter.list).not.toHaveBeenCalled() + }) +}) From 4778e63a645395b7cd75528c36fa6658e8781c29 Mon Sep 17 00:00:00 2001 From: miaobuao Date: Tue, 7 Apr 2026 16:08:21 +0800 Subject: [PATCH 03/18] feat: agent loop --- package.json | 10 +- packages/chatbox/package.json | 33 + packages/chatbox/postcss.config.mjs | 5 + packages/chatbox/rslib.config.ts | 32 + packages/chatbox/src/App.tsx | 865 +++++++ packages/chatbox/src/assets/styles/global.css | 52 + packages/chatbox/src/i18n/index.ts | 30 + packages/chatbox/src/i18n/locales/en.ts | 42 + packages/chatbox/src/i18n/locales/zh.ts | 42 + packages/chatbox/src/index.tsx | 27 + packages/chatbox/src/types.ts | 225 ++ packages/chatbox/tsconfig.json | 24 + packages/chatbox/unocss.config.ts | 35 + pnpm-lock.yaml | 382 +-- src/ai/config.test.ts | 37 + src/ai/config.ts | 121 + src/ai/providers/openai.ts | 32 + src/ai/providers/registry.ts | 10 + src/ai/providers/types.ts | 12 + src/ai/runtime.ts | 168 ++ src/ai/search-path-filter.ts | 107 + src/ai/tool-call-repeat.ts | 58 + src/ai/tools.test.ts | 96 + src/ai/tools.ts | 637 +++++ src/ai/transport/obsidian-fetch.test.ts | 44 + src/ai/transport/obsidian-fetch.ts | 73 + src/ai/tree.test.ts | 63 + src/ai/tree.ts | 32 + src/ai/types.ts | 63 + src/chat/domain.ts | 237 ++ src/chatbox/types.ts | 89 + src/components/ModelEditorModal.ts | 65 + src/components/ProviderEditorModal.ts | 202 ++ src/components/ProvidersManagerModal.ts | 127 + src/i18n/locales/en.ts | 123 + src/i18n/locales/zh.ts | 121 + src/index.ts | 23 + src/services/chat.service.test.ts | 705 ++++++ src/services/chat.service.ts | 2071 +++++++++++++++++ src/services/command.service.ts | 19 + src/settings/ai.ts | 102 + src/settings/index.ts | 13 + src/storage/kv.ts | 20 + src/utils/create-id.ts | 5 + src/views/chatbox.view.ts | 93 + test/mocks/obsidian.ts | 21 + vitest.config.ts | 14 + 47 files changed, 7221 insertions(+), 186 deletions(-) create mode 100644 packages/chatbox/package.json create mode 100644 packages/chatbox/postcss.config.mjs create mode 100644 packages/chatbox/rslib.config.ts create mode 100644 packages/chatbox/src/App.tsx create mode 100644 packages/chatbox/src/assets/styles/global.css create mode 100644 packages/chatbox/src/i18n/index.ts create mode 100644 packages/chatbox/src/i18n/locales/en.ts create mode 100644 packages/chatbox/src/i18n/locales/zh.ts create mode 100644 packages/chatbox/src/index.tsx create mode 100644 packages/chatbox/src/types.ts create mode 100644 packages/chatbox/tsconfig.json create mode 100644 packages/chatbox/unocss.config.ts create mode 100644 src/ai/config.test.ts create mode 100644 src/ai/config.ts create mode 100644 src/ai/providers/openai.ts create mode 100644 src/ai/providers/registry.ts create mode 100644 src/ai/providers/types.ts create mode 100644 src/ai/runtime.ts create mode 100644 src/ai/search-path-filter.ts create mode 100644 src/ai/tool-call-repeat.ts create mode 100644 src/ai/tools.test.ts create mode 100644 src/ai/tools.ts create mode 100644 src/ai/transport/obsidian-fetch.test.ts create mode 100644 src/ai/transport/obsidian-fetch.ts create mode 100644 src/ai/tree.test.ts create mode 100644 src/ai/tree.ts create mode 100644 src/ai/types.ts create mode 100644 src/chat/domain.ts create mode 100644 src/chatbox/types.ts create mode 100644 src/components/ModelEditorModal.ts create mode 100644 src/components/ProviderEditorModal.ts create mode 100644 src/components/ProvidersManagerModal.ts create mode 100644 src/services/chat.service.test.ts create mode 100644 src/services/chat.service.ts create mode 100644 src/settings/ai.ts create mode 100644 src/utils/create-id.ts create mode 100644 src/views/chatbox.view.ts create mode 100644 test/mocks/obsidian.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 5637e30b..fdc85b6e 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "scripts": { "dev": "run-p dev:*", "dev:plugin": "node esbuild.config.mjs", + "dev:chatbox": "pnpm --filter chatbox dev", "dev:webdav-explorer": "pnpm --filter webdav-explorer dev", - "build": "run-s build:webdav-explorer build:plugin", + "build": "run-s build:webdav-explorer build:chatbox build:plugin", + "build:chatbox": "pnpm --filter chatbox build", "build:webdav-explorer": "pnpm --filter webdav-explorer build", "build:plugin": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && swc ./dist/main.js -o main.js", "version": "node version-bump.mjs && git add manifest.json versions.json", "test": "vitest" }, "devDependencies": { + "@ai-sdk/openai": "^3.0.48", "@deanc/esbuild-plugin-postcss": "^1.0.2", "@electron/remote": "^2.1.2", "@nutstore/sso-js": "^0.0.8", @@ -28,6 +31,7 @@ "@typescript-eslint/parser": "5.29.0", "@unocss/postcss": "66.1.0-beta.3", "@vitest/coverage-v8": "^3.1.2", + "ai": "^6.0.149", "assert": "^2.1.0", "async-mutex": "^0.5.0", "blob-polyfill": "^9.0.20240710", @@ -69,7 +73,9 @@ "uuid": "^13.0.0", "vitest": "^3.1.2", "webdav": "^5.7.1", - "webdav-explorer": "workspace: *" + "webdav-explorer": "workspace: *", + "chatbox": "workspace: *", + "zod": "^4.3.6" }, "browser": { "path": "path-browserify", diff --git a/packages/chatbox/package.json b/packages/chatbox/package.json new file mode 100644 index 00000000..44104ed3 --- /dev/null +++ b/packages/chatbox/package.json @@ -0,0 +1,33 @@ +{ + "name": "chatbox", + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch" + }, + "devDependencies": { + "@rsbuild/plugin-babel": "^1.0.4", + "@rsbuild/plugin-solid": "^1.0.5", + "@rslib/core": "^0.5.3", + "@solid-primitives/i18n": "^2.2.0", + "@solid-primitives/media": "^2.3.0", + "@unocss/postcss": "66.1.0-beta.3", + "typescript": "^5.8.2", + "unocss": "66.1.0-beta.3" + }, + "dependencies": { + "solid-js": "^1.9.5" + } +} diff --git a/packages/chatbox/postcss.config.mjs b/packages/chatbox/postcss.config.mjs new file mode 100644 index 00000000..6d0228c7 --- /dev/null +++ b/packages/chatbox/postcss.config.mjs @@ -0,0 +1,5 @@ +import UnoCSS from '@unocss/postcss' + +export default { + plugins: [UnoCSS()], +} diff --git a/packages/chatbox/rslib.config.ts b/packages/chatbox/rslib.config.ts new file mode 100644 index 00000000..7b443f44 --- /dev/null +++ b/packages/chatbox/rslib.config.ts @@ -0,0 +1,32 @@ +import { pluginBabel } from '@rsbuild/plugin-babel' +import { pluginSolid } from '@rsbuild/plugin-solid' +import { defineConfig } from '@rslib/core' + +export default defineConfig({ + source: { + entry: { + index: ['./src/**'], + }, + }, + tools: { + rspack: { + plugins: [], + }, + }, + lib: [ + { + bundle: false, + dts: true, + format: 'esm', + }, + ], + output: { + target: 'web', + }, + plugins: [ + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + }), + pluginSolid(), + ], +}) diff --git a/packages/chatbox/src/App.tsx b/packages/chatbox/src/App.tsx new file mode 100644 index 00000000..7672c622 --- /dev/null +++ b/packages/chatbox/src/App.tsx @@ -0,0 +1,865 @@ +import { For, Match, Show, Switch, createEffect, createSignal, onCleanup } from 'solid-js' +import { t } from './i18n' +import { + ChatMessageContentPart, + ChatTaskRecord, + ChatTimelineFragmentItem, + ChatTimelineMessageItem, + ChatboxProps, +} from './types' + +export type AppProps = ChatboxProps + +function formatTime(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(timestamp) +} + +function formatDuration(task: ChatTaskRecord) { + if (!('startedAt' in task) || typeof task.startedAt !== 'number') { + return '' + } + const end = 'finishedAt' in task && typeof task.finishedAt === 'number' + ? task.finishedAt + : Date.now() + const totalSeconds = Math.max(0, Math.floor((end - task.startedAt) / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + if (minutes > 0) { + return `${minutes}m ${seconds}s` + } + return `${seconds}s` +} + +function formatUsage( + input?: number, + output?: number, + total?: number, +) { + const parts = [] + if (typeof input === 'number') { + parts.push(`in ${input}`) + } + if (typeof output === 'number') { + parts.push(`out ${output}`) + } + if (typeof total === 'number') { + parts.push(`total ${total}`) + } + return parts.join(' · ') +} + +function statusLabel(status: ChatTaskRecord['status']) { + switch (status) { + case 'queued': + return t('taskQueued') + case 'running': + return t('taskRunning') + case 'completed': + return t('taskCompleted') + case 'failed': + return t('taskFailed') + case 'cancelled': + return t('taskCancelled') + } +} + +function statusClass(status: ChatTaskRecord['status']) { + switch (status) { + case 'running': + return 'bg-[var(--color-green-rgb)]/12 text-[var(--text-accent)]' + case 'completed': + return 'bg-[var(--color-cyan-rgb)]/12 text-[var(--text-normal)]' + case 'failed': + return 'bg-[var(--color-red-rgb)]/12 text-[var(--text-error)]' + case 'cancelled': + return 'bg-[var(--background-secondary)] text-[var(--text-muted)]' + default: + return 'bg-[var(--background-secondary)] text-[var(--text-normal)]' + } +} + +function runStateLabel(runState: AppProps['runState']) { + switch (runState) { + case 'thinking': + return t('thinking') + case 'compressing': + return t('compressing') + case 'waiting_for_tools': + return t('processingTools') + default: + return '' + } +} + +function MarkdownContent(props: { + markdown: string + renderMarkdown?: AppProps['renderMarkdown'] +}) { + let el: HTMLDivElement | undefined + let cleanup: (() => void) | undefined + let renderVersion = 0 + + createEffect(() => { + const markdown = props.markdown + const renderMarkdown = props.renderMarkdown + const currentVersion = ++renderVersion + + cleanup?.() + cleanup = undefined + + if (!el) { + return + } + + el.replaceChildren() + + if (!markdown) { + return + } + + if (!renderMarkdown) { + el.textContent = markdown + return + } + + void Promise.resolve(renderMarkdown(el, markdown)).then((nextCleanup) => { + if (currentVersion !== renderVersion) { + if (typeof nextCleanup === 'function') { + nextCleanup() + } + return + } + cleanup = typeof nextCleanup === 'function' ? nextCleanup : undefined + }) + }) + + onCleanup(() => { + renderVersion += 1 + cleanup?.() + cleanup = undefined + el?.replaceChildren() + }) + + return
+} + +function ContentParts(props: { + content?: ChatMessageContentPart[] | null + renderMarkdown?: AppProps['renderMarkdown'] +}) { + return ( + +
+ + {(part) => ( + + + ).text} + renderMarkdown={props.renderMarkdown} + /> + + + ).image_url.url} + alt="" + /> + + +
+									{JSON.stringify(
+										(part as Extract).value,
+										null,
+										2,
+									)}
+								
+
+
+ )} +
+
+
+ ) +} + +function TaskCard(props: { + task: ChatTaskRecord + onCancelTask?: AppProps['onCancelTask'] + compact?: boolean +}) { + const duration = () => formatDuration(props.task) + const detail = () => { + switch (props.task.status) { + case 'completed': + return props.task.summary + case 'failed': + return props.task.summary || props.task.error + case 'cancelled': + return props.task.summary + default: + return '' + } + } + const sourceCount = () => + props.task.status === 'completed' + ? props.task.sourceCount + : props.task.status === 'failed' + ? props.task.sourceCount + : undefined + + return ( +
+
+
+
{props.task.label}
+
{props.task.task}
+
+ + {statusLabel(props.task.status)} + +
+
+ + {t('depth')}: {props.task.depth}/{props.task.maxDepth} + + + + {duration()} + + + + + {t('sources')}: {sourceCount()} + + +
+ +
+ {detail()} +
+
+ +
+
+ {formatTime(props.task.createdAt)} + + {` · ${formatTime((props.task as Extract).finishedAt)}`} + +
+ + + +
+
+
+ ) +} + +function MessageCard(props: { + item: ChatTimelineMessageItem + renderMarkdown?: AppProps['renderMarkdown'] +}) { + const content = () => props.item.message.message.content + const usageText = () => + formatUsage( + props.item.message.meta?.usage?.inputTokens, + props.item.message.meta?.usage?.outputTokens, + props.item.message.meta?.usage?.totalTokens, + ) + + const roleLabel = () => { + if (props.item.message.message.role === 'tool') { + return `Tool: ${props.item.message.message.name || t('tool')}` + } + if (props.item.message.message.role === 'assistant') { + return 'Assistant' + } + if (props.item.message.message.role === 'user') { + return 'User' + } + return 'System' + } + + return ( + + +
{roleLabel()}
+
{formatTime(props.item.message.createdAt)}
+
+ + <> +
{t('params')}
+
+								{props.item.toolCall?.function.arguments || '{}'}
+							
+ +
+
{t('result')}
+ + + } + > +
+
+
{roleLabel()}
+
{formatTime(props.item.message.createdAt)}
+
+ + +
+ + + {[props.item.message.meta?.providerName, props.item.message.meta?.modelName] + .filter(Boolean) + .join('/')} + + + + + {usageText()} + + +
+
+
+
+ ) +} + +function SessionHistoryItem(props: { + session: AppProps['sessionHistory'][number] + isActive: boolean + onSelect: (sessionId: string) => void + onDelete: (sessionId: string) => void +}) { + const activate = () => props.onSelect(props.session.id) + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + activate() + } + }} + > + +
+ +
+
+
+ {props.session.title} +
+
+ {formatTime(props.session.createdAt)} +
+
+ +
+
+ ) +} + +function FragmentDivider(props: { + item: ChatTimelineFragmentItem +}) { + return ( +
+
+
+ {formatTime(props.item.createdAt)} +
+
+ ) +} + +function RunStateCard(props: { + runState: AppProps['runState'] + onStop?: AppProps['onStopActiveRun'] +}) { + const label = () => runStateLabel(props.runState) + const canStop = () => + props.runState === 'thinking' || props.runState === 'waiting_for_tools' + + return ( + +
+
+
+ +
{label()}
+
+ + + +
+
+
+ ) +} + +function PendingList(props: { + pendingMessages: AppProps['pendingMessages'] +}) { + return ( + 0}> +
+
+ {t('pendingMessages')} +
+
+ + {(message) => ( +
+ {message.text} +
+ )} +
+
+
+
+ ) +} + +function App(props: AppProps) { + const [input, setInput] = createSignal('') + const [isComposing, setIsComposing] = createSignal(false) + const [historyOpen, setHistoryOpen] = createSignal(false) + const [tasksOpen, setTasksOpen] = createSignal(false) + const [modelPickerOpen, setModelPickerOpen] = createSignal(false) + const [sessionPendingDeleteId, setSessionPendingDeleteId] = createSignal() + let messagesEl: HTMLDivElement | undefined + let historyEl: HTMLDivElement | undefined + let modelPickerEl: HTMLDivElement | undefined + let previousActiveSessionId = props.activeSessionId + + const hasTasks = () => props.currentSessionTasks.length + props.otherSessionTasks.length > 0 + const runningTaskCount = () => props.currentSessionTasks.filter((task) => task.status === 'running').length + + props.otherSessionTasks.filter((task) => task.status === 'running').length + const isBusy = () => props.runState !== 'idle' + const selectedProvider = () => props.providers.find((provider) => provider.id === props.selectedProviderId) + const modelPickerLabel = () => { + const provider = selectedProvider() + const selectedModel = provider?.models.find((model) => model.id === props.selectedModelId) + return [provider?.name, selectedModel?.name].filter(Boolean).join('/') || t('noModel') + } + + function scrollMessagesToBottom(behavior: ScrollBehavior = 'smooth') { + requestAnimationFrame(() => { + if (!messagesEl) { + return + } + messagesEl.scrollTo({ + top: messagesEl.scrollHeight, + behavior, + }) + }) + } + + createEffect(() => { + const activeSessionId = props.activeSessionId + props.timeline.length + props.currentSessionTasks.length + props.otherSessionTasks.length + props.pendingMessages.length + props.runState + const behavior = previousActiveSessionId !== activeSessionId ? 'auto' : 'smooth' + previousActiveSessionId = activeSessionId + scrollMessagesToBottom(behavior) + }) + + createEffect(() => { + if (!hasTasks() && tasksOpen()) { + setTasksOpen(false) + } + }) + + createEffect(() => { + if (!historyOpen() && !modelPickerOpen()) { + return + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Node)) { + return + } + if (historyEl?.contains(target) || modelPickerEl?.contains(target)) { + return + } + setHistoryOpen(false) + setModelPickerOpen(false) + } + + document.addEventListener('pointerdown', onPointerDown) + onCleanup(() => document.removeEventListener('pointerdown', onPointerDown)) + }) + + async function submit() { + const text = input().trim() + if (!text || !props.canSend) { + return + } + setInput('') + scrollMessagesToBottom('auto') + await props.onSendMessage(text) + } + + async function confirmDeleteSession() { + const sessionId = sessionPendingDeleteId() + if (!sessionId) { + return + } + setSessionPendingDeleteId(undefined) + await props.onDeleteSession(sessionId) + } + + return ( +
+
+
+ +
{props.title || t('newChat')}
+ + + +
+ + +
+
{t('provider')}
+ +
{t('model')}
+ +
+
+
+ +
+
+
+
+
+ {t('history')} +
+
+ +
+
+
+
+ + {(session) => ( + { + props.onSwitchSession(sessionId) + setHistoryOpen(false) + }} + onDelete={(sessionId) => { + setSessionPendingDeleteId(sessionId) + }} + /> + )} + +
+
+
+
+
+ +
+ 0 || props.pendingMessages.length > 0 || isBusy()} + fallback={ +
+ {t('empty')} +
+ } + > +
+ + {(item) => ( + + + + + + + + + )} + + + +
+
+
+ +
+