Compare commits
5 Commits
betterSqli
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3093a9e5d0 | ||
|
|
3274723b1e | ||
|
|
5c724a03a6 | ||
|
|
a95e776699 | ||
|
|
be99f4df71 |
11
package.json
11
package.json
@@ -77,9 +77,7 @@
|
||||
"release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public",
|
||||
"release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public",
|
||||
"release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public",
|
||||
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public",
|
||||
"rebuild": "electron-rebuild -f -w better-sqlite3",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
"release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch",
|
||||
@@ -88,8 +86,6 @@
|
||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||
"@paymoapp/electron-shutdown-handler": "^1.1.2",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"better-sqlite3": "12.4.1",
|
||||
"emoji-picker-element-data": "^1",
|
||||
"express": "^5.1.0",
|
||||
"font-list": "^2.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
@@ -204,7 +200,6 @@
|
||||
"@tiptap/y-tiptap": "^3.0.0",
|
||||
"@truto/turndown-plugin-gfm": "^1.0.2",
|
||||
"@tryfabric/martian": "^1.2.4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cli-progress": "^3",
|
||||
"@types/content-type": "^1.1.9",
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -220,8 +215,8 @@
|
||||
"@types/mime-types": "^3",
|
||||
"@types/node": "^22.17.1",
|
||||
"@types/pako": "^1.0.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1",
|
||||
|
||||
@@ -196,6 +196,9 @@ export enum IpcChannel {
|
||||
File_ValidateNotesDirectory = 'file:validateNotesDirectory',
|
||||
File_StartWatcher = 'file:startWatcher',
|
||||
File_StopWatcher = 'file:stopWatcher',
|
||||
File_PauseWatcher = 'file:pauseWatcher',
|
||||
File_ResumeWatcher = 'file:resumeWatcher',
|
||||
File_BatchUploadMarkdown = 'file:batchUploadMarkdown',
|
||||
File_ShowInFolder = 'file:showInFolder',
|
||||
|
||||
// file service
|
||||
|
||||
@@ -10,7 +10,7 @@ export type LoaderReturn = {
|
||||
messageSource?: 'preprocess' | 'embedding' | 'validation'
|
||||
}
|
||||
|
||||
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
|
||||
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh'
|
||||
|
||||
export type FileChangeEvent = {
|
||||
eventType: FileChangeEventType
|
||||
|
||||
@@ -104,6 +104,12 @@ const router = express
|
||||
logger.warn('No models available from providers', { filter })
|
||||
}
|
||||
|
||||
logger.info('Models response ready', {
|
||||
filter,
|
||||
total: response.total,
|
||||
modelIds: response.data.map((m) => m.id)
|
||||
})
|
||||
|
||||
return res.json(response satisfies ApiModelsResponse)
|
||||
} catch (error: any) {
|
||||
logger.error('Error fetching models', { error })
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ModelsService {
|
||||
|
||||
for (const model of models) {
|
||||
const provider = providers.find((p) => p.id === model.provider)
|
||||
// logger.debug(`Processing model ${model.id}`)
|
||||
logger.debug(`Processing model ${model.id}`)
|
||||
if (!provider) {
|
||||
logger.debug(`Skipping model ${model.id} . Reason: Provider not found.`)
|
||||
continue
|
||||
|
||||
@@ -557,6 +557,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.File_ValidateNotesDirectory, fileManager.validateNotesDirectory.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_StartWatcher, fileManager.startFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_StopWatcher, fileManager.stopFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_PauseWatcher, fileManager.pauseFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ResumeWatcher, fileManager.resumeFileWatcher.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_BatchUploadMarkdown, fileManager.batchUploadMarkdownFiles.bind(fileManager))
|
||||
ipcMain.handle(IpcChannel.File_ShowInFolder, fileManager.showInFolder.bind(fileManager))
|
||||
|
||||
// file service
|
||||
|
||||
@@ -1605,6 +1605,164 @@ class FileStorage {
|
||||
logger.error('Failed to show item in folder:', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch upload markdown files from native File objects
|
||||
* This handles all I/O operations in the Main process to avoid blocking Renderer
|
||||
*/
|
||||
public batchUploadMarkdownFiles = async (
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
filePaths: string[],
|
||||
targetPath: string
|
||||
): Promise<{
|
||||
fileCount: number
|
||||
folderCount: number
|
||||
skippedFiles: number
|
||||
}> => {
|
||||
try {
|
||||
logger.info('Starting batch upload', { fileCount: filePaths.length, targetPath })
|
||||
|
||||
const basePath = path.resolve(targetPath)
|
||||
const MARKDOWN_EXTS = ['.md', '.markdown']
|
||||
|
||||
// Filter markdown files
|
||||
const markdownFiles = filePaths.filter((filePath) => {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
return MARKDOWN_EXTS.includes(ext)
|
||||
})
|
||||
|
||||
const skippedFiles = filePaths.length - markdownFiles.length
|
||||
|
||||
if (markdownFiles.length === 0) {
|
||||
return { fileCount: 0, folderCount: 0, skippedFiles }
|
||||
}
|
||||
|
||||
// Collect unique folders needed
|
||||
const foldersSet = new Set<string>()
|
||||
const fileOperations: Array<{ sourcePath: string; targetPath: string }> = []
|
||||
|
||||
for (const filePath of markdownFiles) {
|
||||
try {
|
||||
// Get relative path if file is from a directory upload
|
||||
const fileName = path.basename(filePath)
|
||||
const relativePath = path.dirname(filePath)
|
||||
|
||||
// Determine target directory structure
|
||||
let targetDir = basePath
|
||||
const folderParts: string[] = []
|
||||
|
||||
// Extract folder structure from file path for nested uploads
|
||||
// This is a simplified version - in real scenario we'd need the original directory structure
|
||||
if (relativePath && relativePath !== '.') {
|
||||
const parts = relativePath.split(path.sep)
|
||||
// Get the last few parts that represent the folder structure within upload
|
||||
const relevantParts = parts.slice(Math.max(0, parts.length - 3))
|
||||
folderParts.push(...relevantParts)
|
||||
}
|
||||
|
||||
// Build target directory path
|
||||
for (const part of folderParts) {
|
||||
targetDir = path.join(targetDir, part)
|
||||
foldersSet.add(targetDir)
|
||||
}
|
||||
|
||||
// Determine final file name
|
||||
const nameWithoutExt = fileName.endsWith('.md')
|
||||
? fileName.slice(0, -3)
|
||||
: fileName.endsWith('.markdown')
|
||||
? fileName.slice(0, -9)
|
||||
: fileName
|
||||
|
||||
const { safeName } = await this.fileNameGuard(_, targetDir, nameWithoutExt, true)
|
||||
const finalPath = path.join(targetDir, safeName + '.md')
|
||||
|
||||
fileOperations.push({ sourcePath: filePath, targetPath: finalPath })
|
||||
} catch (error) {
|
||||
logger.error('Failed to prepare file operation:', error as Error, { filePath })
|
||||
}
|
||||
}
|
||||
|
||||
// Create folders in order (shallow to deep)
|
||||
const sortedFolders = Array.from(foldersSet).sort((a, b) => a.length - b.length)
|
||||
for (const folder of sortedFolders) {
|
||||
try {
|
||||
if (!fs.existsSync(folder)) {
|
||||
await fs.promises.mkdir(folder, { recursive: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Folder already exists or creation failed', { folder, error: (error as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
// Process files in batches
|
||||
const BATCH_SIZE = 10 // Higher batch size since we're in Main process
|
||||
let successCount = 0
|
||||
|
||||
for (let i = 0; i < fileOperations.length; i += BATCH_SIZE) {
|
||||
const batch = fileOperations.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (op) => {
|
||||
// Read from source and write to target in Main process
|
||||
const content = await fs.promises.readFile(op.sourcePath, 'utf-8')
|
||||
await fs.promises.writeFile(op.targetPath, content, 'utf-8')
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
} else {
|
||||
logger.error('Failed to upload file:', result.reason, {
|
||||
file: batch[index].sourcePath
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Batch upload completed', {
|
||||
successCount,
|
||||
folderCount: foldersSet.size,
|
||||
skippedFiles
|
||||
})
|
||||
|
||||
return {
|
||||
fileCount: successCount,
|
||||
folderCount: foldersSet.size,
|
||||
skippedFiles
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Batch upload failed:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause file watcher to prevent events during batch operations
|
||||
*/
|
||||
public pauseFileWatcher = async (): Promise<void> => {
|
||||
if (this.watcher) {
|
||||
logger.debug('Pausing file watcher')
|
||||
// Chokidar doesn't have pause, so we temporarily set a flag
|
||||
// We'll handle this by clearing the debounce timer
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume file watcher and trigger a refresh
|
||||
*/
|
||||
public resumeFileWatcher = async (): Promise<void> => {
|
||||
if (this.watcher && this.currentWatchPath) {
|
||||
logger.debug('Resuming file watcher')
|
||||
// Send a synthetic refresh event to trigger tree reload
|
||||
this.notifyChange('refresh', this.currentWatchPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fileStorage = new FileStorage()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type Client, createClient } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import { mcpApiService } from '@main/apiServer/services/mcp'
|
||||
import type { ModelValidationError } from '@main/apiServer/utils'
|
||||
import { validateModelId } from '@main/apiServer/utils'
|
||||
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
|
||||
import { objectKeys } from '@types'
|
||||
import Database from 'better-sqlite3'
|
||||
import { type BetterSQLite3Database, drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
@@ -32,8 +32,8 @@ const logger = loggerService.withContext('BaseService')
|
||||
* - Connection retry logic with exponential backoff
|
||||
*/
|
||||
export abstract class BaseService {
|
||||
protected static client: Database.Database | null = null
|
||||
protected static db: BetterSQLite3Database<typeof schema> | null = null
|
||||
protected static client: Client | null = null
|
||||
protected static db: LibSQLDatabase<typeof schema> | null = null
|
||||
protected static isInitialized = false
|
||||
protected static initializationPromise: Promise<void> | null = null
|
||||
protected jsonFields: string[] = [
|
||||
@@ -116,7 +116,9 @@ export abstract class BaseService {
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
}
|
||||
|
||||
BaseService.client = new Database(dbPath)
|
||||
BaseService.client = createClient({
|
||||
url: `file:${dbPath}`
|
||||
})
|
||||
|
||||
BaseService.db = drizzle(BaseService.client, { schema })
|
||||
|
||||
@@ -163,12 +165,12 @@ export abstract class BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
protected get database(): BetterSQLite3Database<typeof schema> {
|
||||
protected get database(): LibSQLDatabase<typeof schema> {
|
||||
this.ensureInitialized()
|
||||
return BaseService.db!
|
||||
}
|
||||
|
||||
protected get rawClient(): Database.Database {
|
||||
protected get rawClient(): Client {
|
||||
this.ensureInitialized()
|
||||
return BaseService.client!
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type Client } from '@libsql/client'
|
||||
import { loggerService } from '@logger'
|
||||
import { getResourcePath } from '@main/utils'
|
||||
import type Database from 'better-sqlite3'
|
||||
import { type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
@@ -23,11 +23,11 @@ interface MigrationJournal {
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private db: BetterSQLite3Database<typeof schema>
|
||||
private client: Database.Database
|
||||
private db: LibSQLDatabase<typeof schema>
|
||||
private client: Client
|
||||
private migrationDir: string
|
||||
|
||||
constructor(db: BetterSQLite3Database<typeof schema>, client: Database.Database) {
|
||||
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
|
||||
this.db = db
|
||||
this.client = client
|
||||
this.migrationDir = path.join(getResourcePath(), 'database', 'drizzle')
|
||||
@@ -88,8 +88,8 @@ export class MigrationService {
|
||||
|
||||
private async migrationsTableExists(): Promise<boolean> {
|
||||
try {
|
||||
const rows = this.client.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`).all()
|
||||
return rows.length > 0
|
||||
const table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
|
||||
return table.rows.length > 0
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migrations table status:', { error })
|
||||
throw error
|
||||
@@ -136,7 +136,7 @@ export class MigrationService {
|
||||
|
||||
// Read and execute SQL
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, 'utf-8')
|
||||
this.client.exec(sqlContent)
|
||||
await this.client.executeMultiple(sqlContent)
|
||||
|
||||
// Record migration as applied (store journal idx as version for tracking)
|
||||
const newMigration: NewMigration = {
|
||||
|
||||
@@ -91,18 +91,17 @@ class AgentMessageRepository extends BaseService {
|
||||
return tx ?? this.database
|
||||
}
|
||||
|
||||
private findExistingMessageRow(
|
||||
private async findExistingMessageRow(
|
||||
writer: TxClient,
|
||||
sessionId: string,
|
||||
role: string,
|
||||
messageId: string
|
||||
): SessionMessageRow | null {
|
||||
const candidateRows: SessionMessageRow[] = writer
|
||||
): Promise<SessionMessageRow | null> {
|
||||
const candidateRows: SessionMessageRow[] = await writer
|
||||
.select()
|
||||
.from(sessionMessagesTable)
|
||||
.where(and(eq(sessionMessagesTable.session_id, sessionId), eq(sessionMessagesTable.role, role)))
|
||||
.orderBy(asc(sessionMessagesTable.created_at))
|
||||
.all()
|
||||
|
||||
for (const row of candidateRows) {
|
||||
if (!row?.content) continue
|
||||
@@ -120,9 +119,12 @@ class AgentMessageRepository extends BaseService {
|
||||
return null
|
||||
}
|
||||
|
||||
private upsertMessageSync(
|
||||
private async upsertMessage(
|
||||
params: PersistUserMessageParams | PersistAssistantMessageParams
|
||||
): AgentSessionMessageEntity {
|
||||
): Promise<AgentSessionMessageEntity> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const { sessionId, agentSessionId = '', payload, metadata, createdAt, tx } = params
|
||||
|
||||
if (!payload?.message?.role) {
|
||||
@@ -138,13 +140,13 @@ class AgentMessageRepository extends BaseService {
|
||||
const serializedPayload = this.serializeMessage(payload)
|
||||
const serializedMetadata = this.serializeMetadata(metadata)
|
||||
|
||||
const existingRow = this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
|
||||
const existingRow = await this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
|
||||
|
||||
if (existingRow) {
|
||||
const metadataToPersist = serializedMetadata ?? existingRow.metadata ?? undefined
|
||||
const agentSessionToPersist = agentSessionId || existingRow.agent_session_id || ''
|
||||
|
||||
writer
|
||||
await writer
|
||||
.update(sessionMessagesTable)
|
||||
.set({
|
||||
content: serializedPayload,
|
||||
@@ -153,7 +155,6 @@ class AgentMessageRepository extends BaseService {
|
||||
updated_at: now
|
||||
})
|
||||
.where(eq(sessionMessagesTable.id, existingRow.id))
|
||||
.run()
|
||||
|
||||
return this.deserialize({
|
||||
...existingRow,
|
||||
@@ -174,19 +175,11 @@ class AgentMessageRepository extends BaseService {
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = writer.insert(sessionMessagesTable).values(insertData).returning().all()
|
||||
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||
|
||||
return this.deserialize(saved)
|
||||
}
|
||||
|
||||
private async upsertMessage(
|
||||
params: PersistUserMessageParams | PersistAssistantMessageParams
|
||||
): Promise<AgentSessionMessageEntity> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
return this.upsertMessageSync(params)
|
||||
}
|
||||
|
||||
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
|
||||
return this.upsertMessage({ ...params, agentSessionId: params.agentSessionId ?? '' })
|
||||
}
|
||||
@@ -201,11 +194,11 @@ class AgentMessageRepository extends BaseService {
|
||||
|
||||
const { sessionId, agentSessionId, user, assistant } = params
|
||||
|
||||
const result = this.database.transaction((tx) => {
|
||||
const result = await this.database.transaction(async (tx) => {
|
||||
const exchangeResult: PersistExchangeResult = {}
|
||||
|
||||
if (user?.payload) {
|
||||
exchangeResult.userMessage = this.upsertMessageSync({
|
||||
exchangeResult.userMessage = await this.persistUserMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: user.payload,
|
||||
@@ -216,7 +209,7 @@ class AgentMessageRepository extends BaseService {
|
||||
}
|
||||
|
||||
if (assistant?.payload) {
|
||||
exchangeResult.assistantMessage = this.upsertMessageSync({
|
||||
exchangeResult.assistantMessage = await this.persistAssistantMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: assistant.payload,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default defineConfig({
|
||||
schema: './src/main/services/agents/database/schema/index.ts',
|
||||
out: './resources/database/drizzle',
|
||||
dbCredentials: {
|
||||
url: resolvedDbPath
|
||||
url: `file:${resolvedDbPath}`
|
||||
},
|
||||
verbose: true,
|
||||
strict: true
|
||||
|
||||
@@ -202,9 +202,9 @@ export class AgentService extends BaseService {
|
||||
async deleteAgent(id: string): Promise<boolean> {
|
||||
this.ensureInitialized()
|
||||
|
||||
const result = this.database.delete(agentsTable).where(eq(agentsTable.id, id)).run()
|
||||
const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))
|
||||
|
||||
return result.changes > 0
|
||||
return result.rowsAffected > 0
|
||||
}
|
||||
|
||||
async agentExists(id: string): Promise<boolean> {
|
||||
|
||||
@@ -148,12 +148,11 @@ export class SessionMessageService extends BaseService {
|
||||
async deleteSessionMessage(sessionId: string, messageId: number): Promise<boolean> {
|
||||
this.ensureInitialized()
|
||||
|
||||
const result = this.database
|
||||
const result = await this.database
|
||||
.delete(sessionMessagesTable)
|
||||
.where(and(eq(sessionMessagesTable.id, messageId), eq(sessionMessagesTable.session_id, sessionId)))
|
||||
.run()
|
||||
|
||||
return result.changes > 0
|
||||
return result.rowsAffected > 0
|
||||
}
|
||||
|
||||
async createSessionMessage(
|
||||
|
||||
@@ -270,12 +270,11 @@ export class SessionService extends BaseService {
|
||||
async deleteSession(agentId: string, id: string): Promise<boolean> {
|
||||
this.ensureInitialized()
|
||||
|
||||
const result = this.database
|
||||
const result = await this.database
|
||||
.delete(sessionsTable)
|
||||
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
|
||||
.run()
|
||||
|
||||
return result.changes > 0
|
||||
return result.rowsAffected > 0
|
||||
}
|
||||
|
||||
async sessionExists(agentId: string, id: string): Promise<boolean> {
|
||||
|
||||
@@ -21,11 +21,6 @@ describe('stripLocalCommandTags', () => {
|
||||
'<local-command-stdout>line1</local-command-stdout>\nkeep\n<local-command-stderr>Error</local-command-stderr>'
|
||||
expect(stripLocalCommandTags(input)).toBe('line1\nkeep\nError')
|
||||
})
|
||||
|
||||
it('if no tags present, returns original string', () => {
|
||||
const input = 'just some normal text'
|
||||
expect(stripLocalCommandTags(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude → AiSDK transform', () => {
|
||||
@@ -193,111 +188,6 @@ describe('Claude → AiSDK transform', () => {
|
||||
expect(toolResult.output).toBe('ok')
|
||||
})
|
||||
|
||||
it('handles tool calls without streaming events (no content_block_start/stop)', () => {
|
||||
const state = new ClaudeStreamState({ agentSessionId: '12344' })
|
||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||
|
||||
const messages: SDKMessage[] = [
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'assistant',
|
||||
uuid: uuid(20),
|
||||
message: {
|
||||
id: 'msg-tool-no-stream',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-read',
|
||||
name: 'Read',
|
||||
input: { file_path: '/test.txt' }
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-bash',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls -la' }
|
||||
}
|
||||
],
|
||||
stop_reason: 'tool_use',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20
|
||||
}
|
||||
}
|
||||
} as unknown as SDKMessage,
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'user',
|
||||
uuid: uuid(21),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-read',
|
||||
content: 'file contents',
|
||||
is_error: false
|
||||
}
|
||||
]
|
||||
}
|
||||
} as SDKMessage,
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'user',
|
||||
uuid: uuid(22),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-bash',
|
||||
content: 'total 42\n...',
|
||||
is_error: false
|
||||
}
|
||||
]
|
||||
}
|
||||
} as SDKMessage
|
||||
]
|
||||
|
||||
for (const message of messages) {
|
||||
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||
parts.push(...transformed)
|
||||
}
|
||||
|
||||
const types = parts.map((part) => part.type)
|
||||
expect(types).toEqual(['tool-call', 'tool-call', 'tool-result', 'tool-result'])
|
||||
|
||||
const toolCalls = parts.filter((part) => part.type === 'tool-call') as Extract<
|
||||
(typeof parts)[number],
|
||||
{ type: 'tool-call' }
|
||||
>[]
|
||||
expect(toolCalls).toHaveLength(2)
|
||||
expect(toolCalls[0].toolName).toBe('Read')
|
||||
expect(toolCalls[0].toolCallId).toBe('12344:tool-read')
|
||||
expect(toolCalls[1].toolName).toBe('Bash')
|
||||
expect(toolCalls[1].toolCallId).toBe('12344:tool-bash')
|
||||
|
||||
const toolResults = parts.filter((part) => part.type === 'tool-result') as Extract<
|
||||
(typeof parts)[number],
|
||||
{ type: 'tool-result' }
|
||||
>[]
|
||||
expect(toolResults).toHaveLength(2)
|
||||
// This is the key assertion - toolName should NOT be 'unknown'
|
||||
expect(toolResults[0].toolName).toBe('Read')
|
||||
expect(toolResults[0].toolCallId).toBe('12344:tool-read')
|
||||
expect(toolResults[0].input).toEqual({ file_path: '/test.txt' })
|
||||
expect(toolResults[0].output).toBe('file contents')
|
||||
|
||||
expect(toolResults[1].toolName).toBe('Bash')
|
||||
expect(toolResults[1].toolCallId).toBe('12344:tool-bash')
|
||||
expect(toolResults[1].input).toEqual({ command: 'ls -la' })
|
||||
expect(toolResults[1].output).toBe('total 42\n...')
|
||||
})
|
||||
|
||||
it('handles streaming text completion', () => {
|
||||
const state = new ClaudeStreamState({ agentSessionId: baseStreamMetadata.session_id })
|
||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||
@@ -410,87 +300,4 @@ describe('Claude → AiSDK transform', () => {
|
||||
expect(finishStep.finishReason).toBe('stop')
|
||||
expect(finishStep.usage).toEqual({ inputTokens: 2, outputTokens: 4, totalTokens: 6 })
|
||||
})
|
||||
|
||||
it('emits fallback text when Claude sends a snapshot instead of deltas', () => {
|
||||
const state = new ClaudeStreamState({ agentSessionId: '12344' })
|
||||
const parts: ReturnType<typeof transformSDKMessageToStreamParts>[number][] = []
|
||||
|
||||
const messages: SDKMessage[] = [
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'stream_event',
|
||||
uuid: uuid(30),
|
||||
event: {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: 'msg-fallback',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {}
|
||||
}
|
||||
}
|
||||
} as unknown as SDKMessage,
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'stream_event',
|
||||
uuid: uuid(31),
|
||||
event: {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: {
|
||||
type: 'text',
|
||||
text: ''
|
||||
}
|
||||
}
|
||||
} as unknown as SDKMessage,
|
||||
{
|
||||
...baseStreamMetadata,
|
||||
type: 'assistant',
|
||||
uuid: uuid(32),
|
||||
message: {
|
||||
id: 'msg-fallback-content',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Final answer without streaming deltas.'
|
||||
}
|
||||
],
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 7
|
||||
}
|
||||
}
|
||||
} as unknown as SDKMessage
|
||||
]
|
||||
|
||||
for (const message of messages) {
|
||||
const transformed = transformSDKMessageToStreamParts(message, state)
|
||||
parts.push(...transformed)
|
||||
}
|
||||
|
||||
const types = parts.map((part) => part.type)
|
||||
expect(types).toEqual(['start-step', 'text-start', 'text-delta', 'text-end', 'finish-step'])
|
||||
|
||||
const delta = parts.find((part) => part.type === 'text-delta') as Extract<
|
||||
(typeof parts)[number],
|
||||
{ type: 'text-delta' }
|
||||
>
|
||||
expect(delta.text).toBe('Final answer without streaming deltas.')
|
||||
|
||||
const finish = parts.find((part) => part.type === 'finish-step') as Extract<
|
||||
(typeof parts)[number],
|
||||
{ type: 'finish-step' }
|
||||
>
|
||||
expect(finish.usage).toEqual({ inputTokens: 3, outputTokens: 7, totalTokens: 10 })
|
||||
expect(finish.finishReason).toBe('stop')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,20 +153,6 @@ export class ClaudeStreamState {
|
||||
return this.blocksByIndex.get(index)
|
||||
}
|
||||
|
||||
getFirstOpenTextBlock(): TextBlockState | undefined {
|
||||
const candidates: TextBlockState[] = []
|
||||
for (const block of this.blocksByIndex.values()) {
|
||||
if (block.kind === 'text') {
|
||||
candidates.push(block)
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
candidates.sort((a, b) => a.index - b.index)
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
getToolBlockById(toolCallId: string): ToolBlockState | undefined {
|
||||
const index = this.toolIndexByNamespacedId.get(toolCallId)
|
||||
if (index === undefined) return undefined
|
||||
@@ -231,10 +217,10 @@ export class ClaudeStreamState {
|
||||
* Persists the final input payload for a tool block once the provider signals
|
||||
* completion so that downstream tool results can reference the original call.
|
||||
*/
|
||||
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
|
||||
completeToolBlock(toolCallId: string, input: unknown, providerMetadata?: ProviderMetadata): void {
|
||||
const block = this.getToolBlockByRawId(toolCallId)
|
||||
this.registerToolCall(toolCallId, {
|
||||
toolName,
|
||||
toolName: block?.toolName ?? 'unknown',
|
||||
input,
|
||||
providerMetadata
|
||||
})
|
||||
|
||||
@@ -414,6 +414,23 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' || message.type === 'user') {
|
||||
logger.silly('claude response', {
|
||||
message,
|
||||
content: JSON.stringify(message.message.content)
|
||||
})
|
||||
} else if (message.type === 'stream_event') {
|
||||
// logger.silly('Claude stream event', {
|
||||
// message,
|
||||
// event: JSON.stringify(message.event)
|
||||
// })
|
||||
} else {
|
||||
logger.silly('Claude response', {
|
||||
message,
|
||||
event: JSON.stringify(message)
|
||||
})
|
||||
}
|
||||
|
||||
const chunks = transformSDKMessageToStreamParts(message, streamState)
|
||||
for (const chunk of chunks) {
|
||||
stream.emit('data', {
|
||||
|
||||
@@ -110,7 +110,7 @@ const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata =>
|
||||
* blocks across calls so that incremental deltas can be correlated correctly.
|
||||
*/
|
||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage, state: ClaudeStreamState): AgentStreamPart[] {
|
||||
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
|
||||
logger.silly('Transforming SDKMessage', { message: sdkMessage })
|
||||
switch (sdkMessage.type) {
|
||||
case 'assistant':
|
||||
return handleAssistantMessage(sdkMessage, state)
|
||||
@@ -186,13 +186,14 @@ function handleAssistantMessage(
|
||||
|
||||
for (const block of content) {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
case 'text':
|
||||
if (!isStreamingActive) {
|
||||
const sanitizedText = stripLocalCommandTags(block.text)
|
||||
if (sanitizedText) {
|
||||
textBlocks.push(sanitizedText)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool_use':
|
||||
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
|
||||
break
|
||||
@@ -202,16 +203,7 @@ function handleAssistantMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (textBlocks.length === 0) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
const combinedText = textBlocks.join('')
|
||||
if (!combinedText) {
|
||||
return chunks
|
||||
}
|
||||
|
||||
if (!isStreamingActive) {
|
||||
if (!isStreamingActive && textBlocks.length > 0) {
|
||||
const id = message.uuid?.toString() || generateMessageId()
|
||||
state.beginStep()
|
||||
chunks.push({
|
||||
@@ -227,7 +219,7 @@ function handleAssistantMessage(
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id,
|
||||
text: combinedText,
|
||||
text: textBlocks.join(''),
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
@@ -238,27 +230,7 @@ function handleAssistantMessage(
|
||||
return finalizeNonStreamingStep(message, state, chunks)
|
||||
}
|
||||
|
||||
const existingTextBlock = state.getFirstOpenTextBlock()
|
||||
const fallbackId = existingTextBlock?.id || message.uuid?.toString() || generateMessageId()
|
||||
if (!existingTextBlock) {
|
||||
chunks.push({
|
||||
type: 'text-start',
|
||||
id: fallbackId,
|
||||
providerMetadata
|
||||
})
|
||||
}
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: fallbackId,
|
||||
text: combinedText,
|
||||
providerMetadata
|
||||
})
|
||||
chunks.push({
|
||||
type: 'text-end',
|
||||
id: fallbackId,
|
||||
providerMetadata
|
||||
})
|
||||
return finalizeNonStreamingStep(message, state, chunks)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +252,7 @@ function handleAssistantToolUse(
|
||||
providerExecuted: true,
|
||||
providerMetadata
|
||||
})
|
||||
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
|
||||
state.completeToolBlock(block.id, block.input, providerMetadata)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,9 +459,6 @@ function handleStreamEvent(
|
||||
}
|
||||
|
||||
case 'message_stop': {
|
||||
if (!state.hasActiveStep()) {
|
||||
break
|
||||
}
|
||||
const pending = state.getPendingUsage()
|
||||
chunks.push({
|
||||
type: 'finish-step',
|
||||
|
||||
@@ -220,6 +220,10 @@ const api = {
|
||||
startFileWatcher: (dirPath: string, config?: any) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_StartWatcher, dirPath, config),
|
||||
stopFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_StopWatcher),
|
||||
pauseFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_PauseWatcher),
|
||||
resumeFileWatcher: () => ipcRenderer.invoke(IpcChannel.File_ResumeWatcher),
|
||||
batchUploadMarkdown: (filePaths: string[], targetPath: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.File_BatchUploadMarkdown, filePaths, targetPath),
|
||||
onFileChange: (callback: (data: FileChangeEvent) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
if (data && typeof data === 'object') {
|
||||
|
||||
@@ -1,120 +1,35 @@
|
||||
import 'emoji-picker-element'
|
||||
|
||||
import TwemojiCountryFlagsWoff2 from '@renderer/assets/fonts/country-flag-fonts/TwemojiCountryFlags.woff2?url'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import type { LanguageVarious } from '@renderer/types'
|
||||
import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'
|
||||
// i18n translations from emoji-picker-element
|
||||
import de from 'emoji-picker-element/i18n/de'
|
||||
import en from 'emoji-picker-element/i18n/en'
|
||||
import es from 'emoji-picker-element/i18n/es'
|
||||
import fr from 'emoji-picker-element/i18n/fr'
|
||||
import ja from 'emoji-picker-element/i18n/ja'
|
||||
import pt_PT from 'emoji-picker-element/i18n/pt_PT'
|
||||
import ru_RU from 'emoji-picker-element/i18n/ru_RU'
|
||||
import zh_CN from 'emoji-picker-element/i18n/zh_CN'
|
||||
import type Picker from 'emoji-picker-element/picker'
|
||||
import type { EmojiClickEvent, NativeEmoji } from 'emoji-picker-element/shared'
|
||||
// Emoji data from emoji-picker-element-data (local, no CDN)
|
||||
// Using CLDR format for full multi-language search support (28 languages)
|
||||
import dataDE from 'emoji-picker-element-data/de/cldr/data.json?url'
|
||||
import dataEN from 'emoji-picker-element-data/en/cldr/data.json?url'
|
||||
import dataES from 'emoji-picker-element-data/es/cldr/data.json?url'
|
||||
import dataFR from 'emoji-picker-element-data/fr/cldr/data.json?url'
|
||||
import dataJA from 'emoji-picker-element-data/ja/cldr/data.json?url'
|
||||
import dataPT from 'emoji-picker-element-data/pt/cldr/data.json?url'
|
||||
import dataRU from 'emoji-picker-element-data/ru/cldr/data.json?url'
|
||||
import dataZH from 'emoji-picker-element-data/zh/cldr/data.json?url'
|
||||
import dataZH_HANT from 'emoji-picker-element-data/zh-hant/cldr/data.json?url'
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
onEmojiClick: (emoji: string) => void
|
||||
}
|
||||
|
||||
// Mapping from app locale to emoji-picker-element i18n
|
||||
const i18nMap: Record<LanguageVarious, typeof en> = {
|
||||
'en-US': en,
|
||||
'zh-CN': zh_CN,
|
||||
'zh-TW': zh_CN, // Closest available
|
||||
'de-DE': de,
|
||||
'el-GR': en, // No Greek available, fallback to English
|
||||
'es-ES': es,
|
||||
'fr-FR': fr,
|
||||
'ja-JP': ja,
|
||||
'pt-PT': pt_PT,
|
||||
'ru-RU': ru_RU
|
||||
}
|
||||
|
||||
// Mapping from app locale to emoji data URL
|
||||
// Using CLDR format provides native language search support for all locales
|
||||
const dataSourceMap: Record<LanguageVarious, string> = {
|
||||
'en-US': dataEN,
|
||||
'zh-CN': dataZH,
|
||||
'zh-TW': dataZH_HANT,
|
||||
'de-DE': dataDE,
|
||||
'el-GR': dataEN, // No Greek CLDR available, fallback to English
|
||||
'es-ES': dataES,
|
||||
'fr-FR': dataFR,
|
||||
'ja-JP': dataJA,
|
||||
'pt-PT': dataPT,
|
||||
'ru-RU': dataRU
|
||||
}
|
||||
|
||||
// Mapping from app locale to emoji-picker-element locale string
|
||||
// Must match the data source locale for proper IndexedDB caching
|
||||
const localeMap: Record<LanguageVarious, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-CN': 'zh',
|
||||
'zh-TW': 'zh-hant',
|
||||
'de-DE': 'de',
|
||||
'el-GR': 'en',
|
||||
'es-ES': 'es',
|
||||
'fr-FR': 'fr',
|
||||
'ja-JP': 'ja',
|
||||
'pt-PT': 'pt',
|
||||
'ru-RU': 'ru'
|
||||
}
|
||||
|
||||
const EmojiPicker: FC<Props> = ({ onEmojiClick }) => {
|
||||
const { theme } = useTheme()
|
||||
const { i18n } = useTranslation()
|
||||
const ref = useRef<Picker>(null)
|
||||
const currentLocale = i18n.language as LanguageVarious
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
|
||||
}, [])
|
||||
|
||||
// Configure picker with i18n and dataSource
|
||||
useEffect(() => {
|
||||
const picker = ref.current
|
||||
if (picker) {
|
||||
picker.i18n = i18nMap[currentLocale] || en
|
||||
picker.dataSource = dataSourceMap[currentLocale] || dataEN
|
||||
picker.locale = localeMap[currentLocale] || 'en'
|
||||
}
|
||||
}, [currentLocale])
|
||||
const refValue = ref.current
|
||||
|
||||
useEffect(() => {
|
||||
const picker = ref.current
|
||||
|
||||
if (picker) {
|
||||
const handleEmojiClick = (event: EmojiClickEvent) => {
|
||||
if (refValue) {
|
||||
const handleEmojiClick = (event: any) => {
|
||||
event.stopPropagation()
|
||||
const { detail } = event
|
||||
// Use detail.unicode (processed with skin tone) or fallback to emoji's unicode for native emoji
|
||||
const unicode = detail.unicode || ('unicode' in detail.emoji ? (detail.emoji as NativeEmoji).unicode : '')
|
||||
onEmojiClick(unicode)
|
||||
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
|
||||
}
|
||||
// 添加事件监听器
|
||||
picker.addEventListener('emoji-click', handleEmojiClick)
|
||||
refValue.addEventListener('emoji-click', handleEmojiClick)
|
||||
|
||||
// 清理事件监听器
|
||||
return () => {
|
||||
picker.removeEventListener('emoji-click', handleEmojiClick)
|
||||
refValue.removeEventListener('emoji-click', handleEmojiClick)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
||||
@@ -140,11 +140,11 @@ describe('DynamicVirtualList', () => {
|
||||
// Should call isSticky function during rendering
|
||||
expect(isSticky).toHaveBeenCalled()
|
||||
|
||||
// Should apply sticky styles to sticky items
|
||||
// Sticky items within visible range should have proper z-index but may be absolute until scrolled
|
||||
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
|
||||
expect(stickyItem).toBeInTheDocument()
|
||||
expect(stickyItem).toHaveStyle('position: sticky')
|
||||
expect(stickyItem).toHaveStyle('z-index: 1')
|
||||
// When sticky item is in visible range, it gets z-index but may not be sticky yet
|
||||
expect(stickyItem).toHaveStyle('z-index: 999')
|
||||
})
|
||||
|
||||
it('should apply absolute positioning to non-sticky items', () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
||||
>
|
||||
<div
|
||||
data-index="0"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(0px); width: 100%;"
|
||||
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(0px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-0"
|
||||
@@ -34,7 +34,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
||||
</div>
|
||||
<div
|
||||
data-index="1"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(50px); width: 100%;"
|
||||
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(50px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-1"
|
||||
@@ -44,7 +44,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
|
||||
</div>
|
||||
<div
|
||||
data-index="2"
|
||||
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
|
||||
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(100px); width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="item-2"
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface DynamicVirtualListProps<T> extends InheritedVirtualizerOptions
|
||||
*/
|
||||
isSticky?: (index: number) => boolean
|
||||
|
||||
/**
|
||||
* Get the depth/level of an item for hierarchical sticky positioning
|
||||
* Used with isSticky to determine ancestor relationships
|
||||
*/
|
||||
getItemDepth?: (index: number) => number
|
||||
|
||||
/**
|
||||
* Range extractor function, cannot be used with isSticky
|
||||
*/
|
||||
@@ -101,6 +107,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
size,
|
||||
estimateSize,
|
||||
isSticky,
|
||||
getItemDepth,
|
||||
rangeExtractor: customRangeExtractor,
|
||||
itemContainerStyle,
|
||||
scrollerStyle,
|
||||
@@ -115,7 +122,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
const internalScrollerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = internalScrollerRef
|
||||
|
||||
const activeStickyIndexRef = useRef(0)
|
||||
const activeStickyIndexesRef = useRef<number[]>([])
|
||||
|
||||
const stickyIndexes = useMemo(() => {
|
||||
if (!isSticky) return []
|
||||
@@ -124,21 +131,54 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
|
||||
const internalStickyRangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
// The active sticky index is the last one that is before or at the start of the visible range
|
||||
const newActiveStickyIndex =
|
||||
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? stickyIndexes[0] ?? 0
|
||||
const activeStickies: number[] = []
|
||||
|
||||
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
|
||||
activeStickyIndexRef.current = newActiveStickyIndex
|
||||
if (getItemDepth) {
|
||||
// With depth information, we can build a proper ancestor chain
|
||||
// Find all sticky items before the visible range
|
||||
const stickiesBeforeRange = stickyIndexes.filter((index) => index < range.startIndex)
|
||||
|
||||
if (stickiesBeforeRange.length > 0) {
|
||||
// Find the depth of the first visible item (or last sticky before it)
|
||||
const firstVisibleIndex = range.startIndex
|
||||
const referenceDepth = getItemDepth(firstVisibleIndex)
|
||||
|
||||
// Build ancestor chain: include all sticky parents
|
||||
const ancestorChain: number[] = []
|
||||
let minDepth = referenceDepth
|
||||
|
||||
// Walk backwards from the last sticky before visible range
|
||||
for (let i = stickiesBeforeRange.length - 1; i >= 0; i--) {
|
||||
const stickyIndex = stickiesBeforeRange[i]
|
||||
const stickyDepth = getItemDepth(stickyIndex)
|
||||
|
||||
// Include this sticky if it's a parent (smaller depth) of our reference
|
||||
if (stickyDepth < minDepth) {
|
||||
ancestorChain.unshift(stickyIndex)
|
||||
minDepth = stickyDepth
|
||||
}
|
||||
}
|
||||
|
||||
activeStickies.push(...ancestorChain)
|
||||
}
|
||||
} else {
|
||||
// Fallback: without depth info, just use the last sticky before range
|
||||
const lastStickyBeforeRange = [...stickyIndexes].reverse().find((index) => index < range.startIndex)
|
||||
if (lastStickyBeforeRange !== undefined) {
|
||||
activeStickies.push(lastStickyBeforeRange)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the active sticky index and the default range extractor
|
||||
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
||||
// Update the ref with current active stickies
|
||||
activeStickyIndexesRef.current = activeStickies
|
||||
|
||||
// Merge the active sticky indexes and the default range extractor
|
||||
const next = new Set([...activeStickyIndexesRef.current, ...defaultRangeExtractor(range)])
|
||||
|
||||
// Sort the set to maintain proper order
|
||||
return [...next].sort((a, b) => a - b)
|
||||
},
|
||||
[stickyIndexes]
|
||||
[stickyIndexes, getItemDepth]
|
||||
)
|
||||
|
||||
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
|
||||
@@ -221,14 +261,47 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
|
||||
}}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const isItemSticky = stickyIndexes.includes(virtualItem.index)
|
||||
const isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
|
||||
const isItemActiveSticky = isItemSticky && activeStickyIndexesRef.current.includes(virtualItem.index)
|
||||
|
||||
// Calculate the sticky offset for multi-level sticky headers
|
||||
const activeStickyIndex = isItemActiveSticky ? activeStickyIndexesRef.current.indexOf(virtualItem.index) : -1
|
||||
|
||||
// Calculate cumulative offset based on actual sizes of previous sticky items
|
||||
let stickyOffset = 0
|
||||
if (activeStickyIndex >= 0) {
|
||||
for (let i = 0; i < activeStickyIndex; i++) {
|
||||
const prevStickyIndex = activeStickyIndexesRef.current[i]
|
||||
stickyOffset += estimateSize(prevStickyIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this item is visually covered by sticky items
|
||||
// If covered, disable pointer events to prevent hover/click bleeding through
|
||||
const isCoveredBySticky = (() => {
|
||||
if (!activeStickyIndexesRef.current.length) return false
|
||||
if (isItemActiveSticky) return false // Sticky items themselves are not covered
|
||||
|
||||
// Calculate if this item's visual position is under any sticky header
|
||||
const itemVisualTop = virtualItem.start
|
||||
let totalStickyHeight = 0
|
||||
for (const stickyIdx of activeStickyIndexesRef.current) {
|
||||
totalStickyHeight += estimateSize(stickyIdx)
|
||||
}
|
||||
|
||||
// If item starts within the sticky area, it's covered
|
||||
return itemVisualTop < totalStickyHeight
|
||||
})()
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...itemContainerStyle,
|
||||
position: isItemActiveSticky ? 'sticky' : 'absolute',
|
||||
top: 0,
|
||||
top: isItemActiveSticky ? stickyOffset : 0,
|
||||
left: 0,
|
||||
zIndex: isItemSticky ? 1 : undefined,
|
||||
zIndex: isItemActiveSticky ? 1000 + (100 - activeStickyIndex) : isItemSticky ? 999 : 0,
|
||||
pointerEvents: isCoveredBySticky ? 'none' : 'auto',
|
||||
...(isItemActiveSticky && {
|
||||
backgroundColor: 'var(--color-background)'
|
||||
}),
|
||||
...(horizontal
|
||||
? {
|
||||
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useTimer } from './useTimer'
|
||||
import { loggerService } from '@logger'
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
const logger = loggerService.withContext('useInPlaceEdit')
|
||||
export interface UseInPlaceEditOptions {
|
||||
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
|
||||
onCancel?: () => void
|
||||
@@ -12,14 +12,10 @@ export interface UseInPlaceEditOptions {
|
||||
export interface UseInPlaceEditReturn {
|
||||
isEditing: boolean
|
||||
isSaving: boolean
|
||||
editValue: string
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
startEdit: (initialValue: string) => void
|
||||
saveEdit: () => void
|
||||
cancelEdit: () => void
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleValueChange: (value: string) => void
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.RefObject<HTMLInputElement | null> }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,58 +33,55 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [originalValue, setOriginalValue] = useState('')
|
||||
const originalValueRef = useRef('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
const startEdit = useCallback(
|
||||
(initialValue: string) => {
|
||||
setIsEditing(true)
|
||||
setEditValue(initialValue)
|
||||
setOriginalValue(initialValue)
|
||||
const startEdit = useCallback((initialValue: string) => {
|
||||
setIsEditing(true)
|
||||
setEditValue(initialValue)
|
||||
originalValueRef.current = initialValue
|
||||
}, [])
|
||||
|
||||
setTimeoutTimer(
|
||||
'startEdit',
|
||||
() => {
|
||||
inputRef.current?.focus()
|
||||
if (autoSelectOnStart) {
|
||||
inputRef.current?.select()
|
||||
}
|
||||
},
|
||||
0
|
||||
)
|
||||
},
|
||||
[autoSelectOnStart, setTimeoutTimer]
|
||||
)
|
||||
useLayoutEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus()
|
||||
if (autoSelectOnStart) {
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}
|
||||
}, [autoSelectOnStart, isEditing])
|
||||
|
||||
const saveEdit = useCallback(async () => {
|
||||
if (isSaving) return
|
||||
|
||||
const finalValue = trimOnSave ? editValue.trim() : editValue
|
||||
if (finalValue === originalValueRef.current) {
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const finalValue = trimOnSave ? editValue.trim() : editValue
|
||||
if (finalValue !== originalValue) {
|
||||
await onSave(finalValue)
|
||||
}
|
||||
await onSave(finalValue)
|
||||
setIsEditing(false)
|
||||
setEditValue('')
|
||||
setOriginalValue('')
|
||||
} finally {
|
||||
} catch (error) {
|
||||
logger.error('Error saving in-place edit', { error })
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
|
||||
}, [isSaving, trimOnSave, editValue, onSave])
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setIsEditing(false)
|
||||
setEditValue('')
|
||||
setOriginalValue('')
|
||||
onCancel?.()
|
||||
}, [onCancel])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
if (e.nativeEvent.isComposing) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
@@ -104,37 +97,29 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
|
||||
setEditValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
setEditValue(value)
|
||||
}, [])
|
||||
|
||||
// Handle clicks outside the input to save
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isEditing && inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
saveEdit()
|
||||
}
|
||||
const handleBlur = useCallback(() => {
|
||||
// 这里的逻辑需要注意:
|
||||
// 如果点击了“取消”按钮,可能会先触发 Blur 保存。
|
||||
// 通常 InPlaceEdit 的逻辑是 Blur 即 Save。
|
||||
// 如果不想 Blur 保存,可以去掉这一行,或者判断 relatedTarget。
|
||||
if (!isSaving) {
|
||||
saveEdit()
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
return
|
||||
}, [isEditing, saveEdit])
|
||||
}, [saveEdit, isSaving])
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
isSaving,
|
||||
editValue,
|
||||
inputRef,
|
||||
startEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
handleKeyDown,
|
||||
handleInputChange,
|
||||
handleValueChange
|
||||
inputProps: {
|
||||
ref: inputRef,
|
||||
value: editValue,
|
||||
onChange: handleInputChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
onBlur: handleBlur,
|
||||
disabled: isSaving // 保存时禁用输入
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "New Folder",
|
||||
"untitled_note": "Untitled Note",
|
||||
"upload_failed": "Note upload failed",
|
||||
"upload_success": "Note uploaded success"
|
||||
"upload_files": "Upload Files",
|
||||
"upload_folder": "Upload Folder",
|
||||
"upload_success": "Note uploaded success",
|
||||
"uploading_files": "Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Assistant Response",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "新文件夹",
|
||||
"untitled_note": "无标题笔记",
|
||||
"upload_failed": "笔记上传失败",
|
||||
"upload_success": "笔记上传成功"
|
||||
"upload_files": "上传文件",
|
||||
"upload_folder": "上传文件夹",
|
||||
"upload_success": "笔记上传成功",
|
||||
"uploading_files": "正在上传 {{count}} 个文件..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手响应",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "新資料夾",
|
||||
"untitled_note": "無標題筆記",
|
||||
"upload_failed": "筆記上傳失敗",
|
||||
"upload_success": "筆記上傳成功"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "筆記上傳成功",
|
||||
"uploading_files": "正在上傳 {{count}} 個檔案..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "Neuer Ordner",
|
||||
"untitled_note": "Unbenannte Notiz",
|
||||
"upload_failed": "Notizen-Upload fehlgeschlagen",
|
||||
"upload_success": "Notizen erfolgreich hochgeladen"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Notizen erfolgreich hochgeladen",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Assistenten-Antwort",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "Νέος φάκελος",
|
||||
"untitled_note": "σημείωση χωρίς τίτλο",
|
||||
"upload_failed": "Η σημείωση δεν ανέβηκε",
|
||||
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Απάντηση Βοηθού",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "Nueva carpeta",
|
||||
"untitled_note": "Nota sin título",
|
||||
"upload_failed": "Error al cargar la nota",
|
||||
"upload_success": "Nota cargada con éxito"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Nota cargada con éxito",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Respuesta del asistente",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "nouveau dossier",
|
||||
"untitled_note": "Note sans titre",
|
||||
"upload_failed": "Échec du téléchargement de la note",
|
||||
"upload_success": "Note téléchargée avec succès"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Note téléchargée avec succès",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Réponse de l'assistant",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "新ファイル夹",
|
||||
"untitled_note": "無題のメモ",
|
||||
"upload_failed": "ノートのアップロードに失敗しました",
|
||||
"upload_success": "ノートのアップロードが成功しました"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "ノートのアップロードが成功しました",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "Nova pasta",
|
||||
"untitled_note": "Nota sem título",
|
||||
"upload_failed": "Falha ao carregar a nota",
|
||||
"upload_success": "Nota carregada com sucesso"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Nota carregada com sucesso",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Resposta do assistente",
|
||||
|
||||
@@ -2175,7 +2175,10 @@
|
||||
"untitled_folder": "Новая папка",
|
||||
"untitled_note": "Незаглавленная заметка",
|
||||
"upload_failed": "Не удалось загрузить заметку",
|
||||
"upload_success": "Заметка успешно загружена"
|
||||
"upload_files": "[to be translated]:Upload Files",
|
||||
"upload_folder": "[to be translated]:Upload Folder",
|
||||
"upload_success": "Заметка успешно загружена",
|
||||
"uploading_files": "[to be translated]:Uploading {{count}} files..."
|
||||
},
|
||||
"notification": {
|
||||
"assistant": "Ответ ассистента",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Tag } from 'antd'
|
||||
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
|
||||
@@ -15,63 +16,6 @@ interface ParsedBashOutput {
|
||||
tool_use_error?: string
|
||||
}
|
||||
|
||||
const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null => {
|
||||
if (!output) return null
|
||||
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const hasToolError = output.includes('<tool_use_error>')
|
||||
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
||||
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
||||
const parserError = xmlDoc.querySelector('parsererror')
|
||||
if (parserError) return null
|
||||
|
||||
const getElementText = (tagName: string): string | undefined => {
|
||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||
return element?.textContent?.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
status: getElementText('status'),
|
||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||
stdout: getElementText('stdout'),
|
||||
stderr: getElementText('stderr'),
|
||||
timestamp: getElementText('timestamp'),
|
||||
tool_use_error: getElementText('tool_use_error')
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
|
||||
if (!parsedOutput) return null
|
||||
|
||||
if (parsedOutput.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: 'Error'
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsedOutput.status === 'completed'
|
||||
const isSuccess = parsedOutput.exit_code === 0
|
||||
|
||||
return {
|
||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||
icon:
|
||||
isCompleted && isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||
} as const
|
||||
}
|
||||
|
||||
export function BashOutputTool({
|
||||
input,
|
||||
output
|
||||
@@ -79,8 +23,73 @@ export function BashOutputTool({
|
||||
input: BashOutputToolInput
|
||||
output?: BashOutputToolOutput
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const parsedOutput = parseBashOutput(output)
|
||||
const statusConfig = getStatusConfig(parsedOutput)
|
||||
// 解析 XML 输出
|
||||
const parsedOutput = useMemo(() => {
|
||||
if (!output) return null
|
||||
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
// 检查是否包含 tool_use_error 标签
|
||||
const hasToolError = output.includes('<tool_use_error>')
|
||||
// 包装成有效的 XML(如果还没有根元素)
|
||||
const xmlStr = output.includes('<status>') || hasToolError ? `<root>${output}</root>` : output
|
||||
const xmlDoc = parser.parseFromString(xmlStr, 'application/xml')
|
||||
|
||||
// 检查是否有解析错误
|
||||
const parserError = xmlDoc.querySelector('parsererror')
|
||||
if (parserError) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getElementText = (tagName: string): string | undefined => {
|
||||
const element = xmlDoc.getElementsByTagName(tagName)[0]
|
||||
return element?.textContent?.trim()
|
||||
}
|
||||
|
||||
const result: ParsedBashOutput = {
|
||||
status: getElementText('status'),
|
||||
exit_code: getElementText('exit_code') ? parseInt(getElementText('exit_code')!) : undefined,
|
||||
stdout: getElementText('stdout'),
|
||||
stderr: getElementText('stderr'),
|
||||
timestamp: getElementText('timestamp'),
|
||||
tool_use_error: getElementText('tool_use_error')
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [output])
|
||||
|
||||
// 获取状态配置
|
||||
const statusConfig = useMemo(() => {
|
||||
if (!parsedOutput) return null
|
||||
|
||||
// 如果有 tool_use_error,直接显示错误状态
|
||||
if (parsedOutput.tool_use_error) {
|
||||
return {
|
||||
color: 'danger',
|
||||
icon: <XCircle className="h-3.5 w-3.5" />,
|
||||
text: 'Error'
|
||||
} as const
|
||||
}
|
||||
|
||||
const isCompleted = parsedOutput.status === 'completed'
|
||||
const isSuccess = parsedOutput.exit_code === 0
|
||||
|
||||
return {
|
||||
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
|
||||
icon:
|
||||
isCompleted && isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : isCompleted && !isSuccess ? (
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
),
|
||||
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
|
||||
} as const
|
||||
}, [parsedOutput])
|
||||
|
||||
const children = parsedOutput ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
|
||||
import { AgentToolsType } from './types'
|
||||
|
||||
const removeSystemReminderTags = (text: string): string => {
|
||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
|
||||
}
|
||||
|
||||
const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
|
||||
if (!output) return null
|
||||
|
||||
const toText = (item: TextOutput) => removeSystemReminderTags(item.text)
|
||||
|
||||
if (Array.isArray(output)) {
|
||||
return output
|
||||
.filter((item): item is TextOutput => item.type === 'text')
|
||||
.map(toText)
|
||||
.join('')
|
||||
}
|
||||
|
||||
return removeSystemReminderTags(output)
|
||||
}
|
||||
|
||||
const getOutputStats = (outputString: string | null) => {
|
||||
if (!outputString) return null
|
||||
|
||||
const bytes = new Blob([outputString]).size
|
||||
const formatSize = (size: number) => {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return {
|
||||
lineCount: outputString.split('\n').length,
|
||||
fileSize: bytes,
|
||||
formatSize
|
||||
}
|
||||
}
|
||||
|
||||
export function ReadTool({
|
||||
input,
|
||||
output
|
||||
@@ -49,8 +14,50 @@ export function ReadTool({
|
||||
input: ReadToolInputType
|
||||
output?: ReadToolOutputType
|
||||
}): NonNullable<CollapseProps['items']>[number] {
|
||||
const outputString = normalizeOutputString(output)
|
||||
const stats = getOutputStats(outputString)
|
||||
// 移除 system-reminder 标签及其内容的辅助函数
|
||||
const removeSystemReminderTags = (text: string): string => {
|
||||
// 使用正则表达式匹配 <system-reminder> 标签及其内容,包括换行符
|
||||
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, '')
|
||||
}
|
||||
|
||||
// 将 output 统一转换为字符串
|
||||
const outputString = useMemo(() => {
|
||||
if (!output) return null
|
||||
|
||||
let processedOutput: string
|
||||
|
||||
// 如果是 TextOutput[] 类型,提取所有 text 内容
|
||||
if (Array.isArray(output)) {
|
||||
processedOutput = output
|
||||
.filter((item): item is TextOutput => item.type === 'text')
|
||||
.map((item) => removeSystemReminderTags(item.text))
|
||||
.join('')
|
||||
} else {
|
||||
// 如果是字符串,直接使用
|
||||
processedOutput = output
|
||||
}
|
||||
|
||||
// 移除 system-reminder 标签及其内容
|
||||
return removeSystemReminderTags(processedOutput)
|
||||
}, [output])
|
||||
|
||||
// 如果有输出,计算统计信息
|
||||
const stats = useMemo(() => {
|
||||
if (!outputString) return null
|
||||
|
||||
const bytes = new Blob([outputString]).size
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return {
|
||||
lineCount: outputString.split('\n').length,
|
||||
fileSize: bytes,
|
||||
formatSize
|
||||
}
|
||||
}, [outputString])
|
||||
|
||||
return {
|
||||
key: AgentToolsType.Read,
|
||||
|
||||
@@ -11,24 +11,11 @@ interface UnknownToolProps {
|
||||
output?: unknown
|
||||
}
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
if (name.startsWith('mcp__')) {
|
||||
const parts = name.substring(5).split('__')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}:${parts.slice(1).join(':')}`
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const getToolDescription = (toolName: string) => {
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return 'MCP Server Tool'
|
||||
}
|
||||
return 'Tool'
|
||||
}
|
||||
|
||||
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
|
||||
export function UnknownToolRenderer({
|
||||
toolName = '',
|
||||
input,
|
||||
output
|
||||
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
||||
const { highlightCode } = useCodeStyle()
|
||||
const [inputHtml, setInputHtml] = useState<string>('')
|
||||
const [outputHtml, setOutputHtml] = useState<string>('')
|
||||
@@ -47,49 +34,58 @@ const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unkno
|
||||
}
|
||||
}, [output, highlightCode])
|
||||
|
||||
if (input === undefined && output === undefined) {
|
||||
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||
const getToolDisplayName = (name: string) => {
|
||||
if (name.startsWith('mcp__')) {
|
||||
const parts = name.substring(5).split('__')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}:${parts.slice(1).join(':')}`
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
const getToolDescription = () => {
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return 'MCP Server Tool'
|
||||
}
|
||||
return 'Tool'
|
||||
}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnknownToolRenderer({
|
||||
toolName = '',
|
||||
input,
|
||||
output
|
||||
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
|
||||
return {
|
||||
key: 'unknown-tool',
|
||||
label: (
|
||||
<ToolTitle
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label={getToolDisplayName(toolName)}
|
||||
params={getToolDescription(toolName)}
|
||||
params={getToolDescription()}
|
||||
/>
|
||||
),
|
||||
children: <UnknownToolContent input={input} output={output} />
|
||||
children: (
|
||||
<div className="space-y-3">
|
||||
{input !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
|
||||
<div
|
||||
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
|
||||
dangerouslySetInnerHTML={{ __html: inputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{output !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
|
||||
<div
|
||||
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
|
||||
dangerouslySetInnerHTML={{ __html: outputHtml }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{input === undefined && output === undefined && (
|
||||
<div className="text-foreground-500 text-xs">No data available for this tool</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Collapse } from 'antd'
|
||||
// 导出所有类型
|
||||
export * from './types'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
// 导入所有渲染器
|
||||
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
|
||||
import { BashOutputTool } from './BashOutputTool'
|
||||
@@ -55,19 +57,22 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
|
||||
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
|
||||
}
|
||||
|
||||
// 统一的渲染组件
|
||||
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
|
||||
// 统一的渲染函数
|
||||
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
|
||||
const Renderer = toolRenderers[toolName]
|
||||
const renderedItem = Renderer
|
||||
? Renderer({ input: input as any, output: output as any })
|
||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||
|
||||
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
|
||||
...renderedItem,
|
||||
classNames: {
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const toolContentItem = useMemo(() => {
|
||||
const rendered = Renderer
|
||||
? Renderer({ input: input as any, output: output as any })
|
||||
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
|
||||
return {
|
||||
...rendered,
|
||||
classNames: {
|
||||
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
|
||||
} as NonNullable<CollapseProps['items']>[number]['classNames']
|
||||
} as NonNullable<CollapseProps['items']>[number]
|
||||
}, [Renderer, input, output, toolName])
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
@@ -93,7 +98,5 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
|
||||
)
|
||||
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
|
||||
const targetSession = useDeferredValue(_targetSession)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
|
||||
const { isEditing, isSaving, startEdit, inputProps } = useInPlaceEdit({
|
||||
onSave: async (value) => {
|
||||
if (value !== session.name) {
|
||||
await updateSession({ id: session.id, name: value })
|
||||
@@ -179,14 +179,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
|
||||
{isFulfilled && !isActive && <FulfilledIndicator />}
|
||||
<SessionNameContainer>
|
||||
{isEditing ? (
|
||||
<SessionEditInput
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleValueChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
style={{ opacity: isSaving ? 0.5 : 1 }}
|
||||
/>
|
||||
<SessionEditInput {...inputProps} style={{ opacity: isSaving ? 0.5 : 1 }} />
|
||||
) : (
|
||||
<>
|
||||
<SessionName>
|
||||
|
||||
@@ -79,7 +79,7 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
const deleteTimerRef = useRef<NodeJS.Timeout>(null)
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
||||
|
||||
const topicEdit = useInPlaceEdit({
|
||||
const { startEdit, isEditing, inputProps } = useInPlaceEdit({
|
||||
onSave: (name: string) => {
|
||||
const topic = assistant.topics.find((t) => t.id === editingTopicId)
|
||||
if (topic && name !== topic.name) {
|
||||
@@ -520,29 +520,23 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
|
||||
<TopicListItem
|
||||
onContextMenu={() => setTargetTopic(topic)}
|
||||
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
|
||||
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||
onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)}
|
||||
style={{
|
||||
borderRadius,
|
||||
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
|
||||
cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer'
|
||||
}}>
|
||||
{isPending(topic.id) && !isActive && <PendingIndicator />}
|
||||
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
|
||||
<TopicNameContainer>
|
||||
{editingTopicId === topic.id && topicEdit.isEditing ? (
|
||||
<TopicEditInput
|
||||
ref={topicEdit.inputRef}
|
||||
value={topicEdit.editValue}
|
||||
onChange={topicEdit.handleInputChange}
|
||||
onKeyDown={topicEdit.handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{editingTopicId === topic.id && isEditing ? (
|
||||
<TopicEditInput {...inputProps} onClick={(e) => e.stopPropagation()} />
|
||||
) : (
|
||||
<TopicName
|
||||
className={getTopicNameClassName()}
|
||||
title={topicName}
|
||||
onDoubleClick={() => {
|
||||
setEditingTopicId(topic.id)
|
||||
topicEdit.startEdit(topic.name)
|
||||
startEdit(topic.name)
|
||||
}}>
|
||||
{topicName}
|
||||
</TopicName>
|
||||
|
||||
@@ -295,6 +295,16 @@ const NotesPage: FC = () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'refresh': {
|
||||
// 批量操作完成后的单次刷新
|
||||
logger.debug('Received refresh event, triggering tree refresh')
|
||||
const refresh = refreshTreeRef.current
|
||||
if (refresh) {
|
||||
await refresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'add':
|
||||
case 'addDir':
|
||||
case 'unlink':
|
||||
@@ -621,7 +631,27 @@ const NotesPage: FC = () => {
|
||||
throw new Error('No folder path selected')
|
||||
}
|
||||
|
||||
const result = await uploadNotes(files, targetFolderPath)
|
||||
// Validate uploadNotes function is available
|
||||
if (typeof uploadNotes !== 'function') {
|
||||
logger.error('uploadNotes function is not available', { uploadNotes })
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
return
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof uploadNotes>>
|
||||
try {
|
||||
result = await uploadNotes(files, targetFolderPath)
|
||||
} catch (uploadError) {
|
||||
logger.error('Upload operation failed:', uploadError as Error)
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
// Validate result object
|
||||
if (!result || typeof result !== 'object') {
|
||||
logger.error('Invalid upload result:', { result })
|
||||
window.toast.error(t('notes.upload_failed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查上传结果
|
||||
if (result.fileCount === 0) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
498
src/renderer/src/pages/notes/components/TreeNode.tsx
Normal file
498
src/renderer/src/pages/notes/components/TreeNode.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import HighlightText from '@renderer/components/HighlightText'
|
||||
import {
|
||||
useNotesActions,
|
||||
useNotesDrag,
|
||||
useNotesEditing,
|
||||
useNotesSearch,
|
||||
useNotesSelection,
|
||||
useNotesUI
|
||||
} from '@renderer/pages/notes/context/NotesContexts'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { SearchMatch, SearchResult } from '@renderer/services/NotesSearchService'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { Dropdown } from 'antd'
|
||||
import { ChevronDown, ChevronRight, File, FilePlus, Folder, FolderOpen } from 'lucide-react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: NotesTreeNode | SearchResult
|
||||
depth: number
|
||||
renderChildren?: boolean
|
||||
onHintClick?: () => void
|
||||
}
|
||||
|
||||
const TreeNode = memo<TreeNodeProps>(({ node, depth, renderChildren = true, onHintClick }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Use split contexts - only subscribe to what this node needs
|
||||
const { selectedFolderId, activeNodeId } = useNotesSelection()
|
||||
const { editingNodeId, renamingNodeIds, newlyRenamedNodeIds, inPlaceEdit } = useNotesEditing()
|
||||
const { draggedNodeId, dragOverNodeId, dragPosition, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd } =
|
||||
useNotesDrag()
|
||||
const { searchKeyword, showMatches } = useNotesSearch()
|
||||
const { openDropdownKey } = useNotesUI()
|
||||
const { getMenuItems, onSelectNode, onToggleExpanded, onDropdownOpenChange } = useNotesActions()
|
||||
|
||||
const [showAllMatches, setShowAllMatches] = useState(false)
|
||||
const { isEditing: isInputEditing, inputProps } = inPlaceEdit
|
||||
|
||||
// 检查是否是 hint 节点
|
||||
const isHintNode = node.type === 'hint'
|
||||
|
||||
// 检查是否是搜索结果
|
||||
const searchResult = 'matchType' in node ? (node as SearchResult) : null
|
||||
const hasMatches = searchResult && searchResult.matches && searchResult.matches.length > 0
|
||||
|
||||
// 处理匹配项点击
|
||||
const handleMatchClick = useCallback(
|
||||
(match: SearchMatch) => {
|
||||
// 发送定位事件
|
||||
EventEmitter.emit(EVENT_NAMES.LOCATE_NOTE_LINE, {
|
||||
noteId: node.id,
|
||||
lineNumber: match.lineNumber,
|
||||
lineContent: match.lineContent
|
||||
})
|
||||
},
|
||||
[node]
|
||||
)
|
||||
|
||||
const isActive = selectedFolderId ? node.type === 'folder' && node.id === selectedFolderId : node.id === activeNodeId
|
||||
const isEditing = editingNodeId === node.id && isInputEditing
|
||||
const isRenaming = renamingNodeIds.has(node.id)
|
||||
const isNewlyRenamed = newlyRenamedNodeIds.has(node.id)
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
const isDragging = draggedNodeId === node.id
|
||||
const isDragOver = dragOverNodeId === node.id
|
||||
const isDragBefore = isDragOver && dragPosition === 'before'
|
||||
const isDragInside = isDragOver && dragPosition === 'inside'
|
||||
const isDragAfter = isDragOver && dragPosition === 'after'
|
||||
|
||||
const getNodeNameClassName = () => {
|
||||
if (isRenaming) return 'shimmer'
|
||||
if (isNewlyRenamed) return 'typing'
|
||||
return ''
|
||||
}
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (!searchKeyword) {
|
||||
return node.name
|
||||
}
|
||||
|
||||
const name = node.name ?? ''
|
||||
if (!name) {
|
||||
return name
|
||||
}
|
||||
|
||||
const keyword = searchKeyword
|
||||
const nameLower = name.toLowerCase()
|
||||
const keywordLower = keyword.toLowerCase()
|
||||
const matchStart = nameLower.indexOf(keywordLower)
|
||||
|
||||
if (matchStart === -1) {
|
||||
return name
|
||||
}
|
||||
|
||||
const matchEnd = matchStart + keyword.length
|
||||
const beforeMatch = Math.min(2, matchStart)
|
||||
const contextStart = matchStart - beforeMatch
|
||||
const contextLength = 50
|
||||
const contextEnd = Math.min(name.length, matchEnd + contextLength)
|
||||
|
||||
const prefix = contextStart > 0 ? '...' : ''
|
||||
const suffix = contextEnd < name.length ? '...' : ''
|
||||
|
||||
return prefix + name.substring(contextStart, contextEnd) + suffix
|
||||
}, [node.name, searchKeyword])
|
||||
|
||||
// Special render for hint nodes
|
||||
if (isHintNode) {
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<TreeNodeContainer active={false} depth={depth}>
|
||||
<TreeNodeContent>
|
||||
<NodeIcon>
|
||||
<FilePlus size={16} />
|
||||
</NodeIcon>
|
||||
<DropHintText onClick={onHintClick}>{t('notes.drop_markdown_hint')}</DropHintText>
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
<Dropdown
|
||||
menu={{ items: getMenuItems(node as NotesTreeNode) }}
|
||||
trigger={['contextMenu']}
|
||||
open={openDropdownKey === node.id}
|
||||
onOpenChange={(open) => onDropdownOpenChange(open ? node.id : null)}>
|
||||
<div onContextMenu={(e) => e.stopPropagation()}>
|
||||
<TreeNodeContainer
|
||||
active={isActive}
|
||||
depth={depth}
|
||||
isDragging={isDragging}
|
||||
isDragOver={isDragOver}
|
||||
isDragBefore={isDragBefore}
|
||||
isDragInside={isDragInside}
|
||||
isDragAfter={isDragAfter}
|
||||
draggable={!isEditing}
|
||||
data-node-id={node.id}
|
||||
onDragStart={(e) => onDragStart(e, node as NotesTreeNode)}
|
||||
onDragOver={(e) => onDragOver(e, node as NotesTreeNode)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node as NotesTreeNode)}
|
||||
onDragEnd={onDragEnd}>
|
||||
<TreeNodeContent onClick={() => onSelectNode(node as NotesTreeNode)}>
|
||||
<NodeIndent depth={depth} />
|
||||
|
||||
{node.type === 'folder' && (
|
||||
<ExpandIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleExpanded(node.id)
|
||||
}}
|
||||
title={node.expanded ? t('notes.collapse') : t('notes.expand')}>
|
||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ExpandIcon>
|
||||
)}
|
||||
|
||||
<NodeIcon>
|
||||
{node.type === 'folder' ? (
|
||||
node.expanded ? (
|
||||
<FolderOpen size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
)
|
||||
) : (
|
||||
<File size={16} />
|
||||
)}
|
||||
</NodeIcon>
|
||||
|
||||
{isEditing ? (
|
||||
<EditInput {...inputProps} onClick={(e) => e.stopPropagation()} autoFocus />
|
||||
) : (
|
||||
<NodeNameContainer>
|
||||
<NodeName className={getNodeNameClassName()}>
|
||||
{searchKeyword ? <HighlightText text={displayName} keyword={searchKeyword} /> : node.name}
|
||||
</NodeName>
|
||||
{searchResult && searchResult.matchType && searchResult.matchType !== 'filename' && (
|
||||
<MatchBadge matchType={searchResult.matchType}>
|
||||
{searchResult.matchType === 'both' ? t('notes.search.both') : t('notes.search.content')}
|
||||
</MatchBadge>
|
||||
)}
|
||||
</NodeNameContainer>
|
||||
)}
|
||||
</TreeNodeContent>
|
||||
</TreeNodeContainer>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{showMatches && hasMatches && (
|
||||
<SearchMatchesContainer depth={depth}>
|
||||
{(showAllMatches ? searchResult!.matches! : searchResult!.matches!.slice(0, 3)).map((match, idx) => (
|
||||
<MatchItem key={idx} onClick={() => handleMatchClick(match)}>
|
||||
<MatchLineNumber>{match.lineNumber}</MatchLineNumber>
|
||||
<MatchContext>
|
||||
<HighlightText text={match.context} keyword={searchKeyword} />
|
||||
</MatchContext>
|
||||
</MatchItem>
|
||||
))}
|
||||
{searchResult!.matches!.length > 3 && (
|
||||
<MoreMatches
|
||||
depth={depth}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAllMatches(!showAllMatches)
|
||||
}}>
|
||||
{showAllMatches ? (
|
||||
<>
|
||||
<ChevronDown size={12} style={{ marginRight: 4 }} />
|
||||
{t('notes.search.show_less')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight size={12} style={{ marginRight: 4 }} />+{searchResult!.matches!.length - 3}{' '}
|
||||
{t('notes.search.more_matches')}
|
||||
</>
|
||||
)}
|
||||
</MoreMatches>
|
||||
)}
|
||||
</SearchMatchesContainer>
|
||||
)}
|
||||
|
||||
{renderChildren && node.type === 'folder' && node.expanded && hasChildren && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode key={child.id} node={child} depth={depth + 1} renderChildren={renderChildren} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const TreeNodeContainer = styled.div<{
|
||||
active: boolean
|
||||
depth: number
|
||||
isDragging?: boolean
|
||||
isDragOver?: boolean
|
||||
isDragBefore?: boolean
|
||||
isDragInside?: boolean
|
||||
isDragAfter?: boolean
|
||||
}>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
/* CRITICAL: Must have fully opaque background for sticky to work properly */
|
||||
/* Transparent/semi-transparent backgrounds will show content bleeding through when sticky */
|
||||
background-color: ${(props) => {
|
||||
if (props.isDragInside) return 'var(--color-primary-background)'
|
||||
// Use hover color for active state - it's guaranteed to be opaque
|
||||
if (props.active) return 'var(--color-hover, var(--color-background-mute))'
|
||||
return 'var(--color-background)'
|
||||
}};
|
||||
border: 0.5px solid
|
||||
${(props) => {
|
||||
if (props.isDragInside) return 'var(--color-primary)'
|
||||
if (props.active) return 'var(--color-border)'
|
||||
return 'transparent'
|
||||
}};
|
||||
opacity: ${(props) => (props.isDragging ? 0.5 : 1)};
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
|
||||
.node-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加拖拽指示线 */
|
||||
${(props) =>
|
||||
props.isDragBefore &&
|
||||
`
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.isDragAfter &&
|
||||
`
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const TreeNodeContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
export const NodeIndent = styled.div<{ depth: number }>`
|
||||
width: ${(props) => props.depth * 16}px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export const ExpandIcon = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-2);
|
||||
margin-right: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
|
||||
export const NodeIcon = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
color: var(--color-text-2);
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export const NodeName = styled.div`
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
position: relative;
|
||||
will-change: background-position, width;
|
||||
|
||||
--color-shimmer-mid: var(--color-text-1);
|
||||
--color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent);
|
||||
|
||||
&.shimmer {
|
||||
background: linear-gradient(to left, var(--color-shimmer-end), var(--color-shimmer-mid), var(--color-shimmer-end));
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: shimmer 3s linear infinite;
|
||||
}
|
||||
|
||||
&.typing {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
animation: typewriter 0.5s steps(40, end);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typewriter {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SearchMatchesContainer = styled.div<{ depth: number }>`
|
||||
margin-left: ${(props) => props.depth * 16 + 40}px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid var(--color-primary-soft);
|
||||
`
|
||||
|
||||
export const NodeNameContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`
|
||||
|
||||
export const MatchBadge = styled.span<{ matchType: string }>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
border-radius: 2px;
|
||||
background-color: ${(props) =>
|
||||
props.matchType === 'both' ? 'var(--color-primary-soft)' : 'var(--color-background-mute)'};
|
||||
color: ${(props) => (props.matchType === 'both' ? 'var(--color-primary)' : 'var(--color-text-3)')};
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export const MatchItem = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
margin-left: -6px;
|
||||
margin-right: -6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export const MatchLineNumber = styled.span`
|
||||
color: var(--color-text-3);
|
||||
font-family: monospace;
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
`
|
||||
|
||||
export const MatchContext = styled.div`
|
||||
color: var(--color-text-2);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
`
|
||||
|
||||
export const MoreMatches = styled.div<{ depth: number }>`
|
||||
margin-top: 4px;
|
||||
padding: 4px 6px;
|
||||
margin-left: -6px;
|
||||
margin-right: -6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-2);
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
`
|
||||
|
||||
const EditInput = styled.input`
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
`
|
||||
|
||||
const DropHintText = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
export default TreeNode
|
||||
109
src/renderer/src/pages/notes/context/NotesContexts.tsx
Normal file
109
src/renderer/src/pages/notes/context/NotesContexts.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { UseInPlaceEditReturn } from '@renderer/hooks/useInPlaceEdit'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
// ==================== 1. Actions Context (Static, rarely changes) ====================
|
||||
export interface NotesActionsContextType {
|
||||
getMenuItems: (node: NotesTreeNode) => MenuProps['items']
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
onToggleExpanded: (nodeId: string) => void
|
||||
onDropdownOpenChange: (key: string | null) => void
|
||||
}
|
||||
|
||||
export const NotesActionsContext = createContext<NotesActionsContextType | null>(null)
|
||||
|
||||
export const useNotesActions = () => {
|
||||
const context = use(NotesActionsContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesActions must be used within NotesActionsContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== 2. Selection Context (Low frequency updates) ====================
|
||||
export interface NotesSelectionContextType {
|
||||
selectedFolderId?: string | null
|
||||
activeNodeId?: string
|
||||
}
|
||||
|
||||
export const NotesSelectionContext = createContext<NotesSelectionContextType | null>(null)
|
||||
|
||||
export const useNotesSelection = () => {
|
||||
const context = use(NotesSelectionContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesSelection must be used within NotesSelectionContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== 3. Editing Context (Medium frequency updates) ====================
|
||||
export interface NotesEditingContextType {
|
||||
editingNodeId: string | null
|
||||
renamingNodeIds: Set<string>
|
||||
newlyRenamedNodeIds: Set<string>
|
||||
inPlaceEdit: UseInPlaceEditReturn
|
||||
}
|
||||
|
||||
export const NotesEditingContext = createContext<NotesEditingContextType | null>(null)
|
||||
|
||||
export const useNotesEditing = () => {
|
||||
const context = use(NotesEditingContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesEditing must be used within NotesEditingContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== 4. Drag Context (High frequency updates) ====================
|
||||
export interface NotesDragContextType {
|
||||
draggedNodeId: string | null
|
||||
dragOverNodeId: string | null
|
||||
dragPosition: 'before' | 'inside' | 'after'
|
||||
onDragStart: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragOver: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent, node: NotesTreeNode) => void
|
||||
onDragEnd: () => void
|
||||
}
|
||||
|
||||
export const NotesDragContext = createContext<NotesDragContextType | null>(null)
|
||||
|
||||
export const useNotesDrag = () => {
|
||||
const context = use(NotesDragContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesDrag must be used within NotesDragContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== 5. Search Context (Medium frequency updates) ====================
|
||||
export interface NotesSearchContextType {
|
||||
searchKeyword: string
|
||||
showMatches: boolean
|
||||
}
|
||||
|
||||
export const NotesSearchContext = createContext<NotesSearchContextType | null>(null)
|
||||
|
||||
export const useNotesSearch = () => {
|
||||
const context = use(NotesSearchContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesSearch must be used within NotesSearchContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== 6. UI Context (Medium frequency updates) ====================
|
||||
export interface NotesUIContextType {
|
||||
openDropdownKey: string | null
|
||||
}
|
||||
|
||||
export const NotesUIContext = createContext<NotesUIContextType | null>(null)
|
||||
|
||||
export const useNotesUI = () => {
|
||||
const context = use(NotesUIContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotesUI must be used within NotesUIContext.Provider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
101
src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
Normal file
101
src/renderer/src/pages/notes/hooks/useNotesDragAndDrop.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface UseNotesDragAndDropProps {
|
||||
onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
|
||||
}
|
||||
|
||||
export const useNotesDragAndDrop = ({ onMoveNode }: UseNotesDragAndDropProps) => {
|
||||
const [draggedNodeId, setDraggedNodeId] = useState<string | null>(null)
|
||||
const [dragOverNodeId, setDragOverNodeId] = useState<string | null>(null)
|
||||
const [dragPosition, setDragPosition] = useState<'before' | 'inside' | 'after'>('inside')
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||
setDraggedNodeId(node.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', node.id)
|
||||
|
||||
dragNodeRef.current = e.currentTarget as HTMLDivElement
|
||||
|
||||
// Create ghost element
|
||||
if (e.currentTarget.parentElement) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const ghostElement = e.currentTarget.cloneNode(true) as HTMLElement
|
||||
ghostElement.style.width = `${rect.width}px`
|
||||
ghostElement.style.opacity = '0.7'
|
||||
ghostElement.style.position = 'absolute'
|
||||
ghostElement.style.top = '-1000px'
|
||||
document.body.appendChild(ghostElement)
|
||||
e.dataTransfer.setDragImage(ghostElement, 10, 10)
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(ghostElement)
|
||||
}, 0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, node: NotesTreeNode) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
|
||||
if (draggedNodeId === node.id) {
|
||||
return
|
||||
}
|
||||
|
||||
setDragOverNodeId(node.id)
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const mouseY = e.clientY
|
||||
const thresholdTop = rect.top + rect.height * 0.3
|
||||
const thresholdBottom = rect.bottom - rect.height * 0.3
|
||||
|
||||
if (mouseY < thresholdTop) {
|
||||
setDragPosition('before')
|
||||
} else if (mouseY > thresholdBottom) {
|
||||
setDragPosition('after')
|
||||
} else {
|
||||
setDragPosition(node.type === 'folder' ? 'inside' : 'after')
|
||||
}
|
||||
},
|
||||
[draggedNodeId]
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragOverNodeId(null)
|
||||
setDragPosition('inside')
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, targetNode: NotesTreeNode) => {
|
||||
e.preventDefault()
|
||||
const draggedId = e.dataTransfer.getData('text/plain')
|
||||
|
||||
if (draggedId && draggedId !== targetNode.id) {
|
||||
onMoveNode(draggedId, targetNode.id, dragPosition)
|
||||
}
|
||||
|
||||
setDraggedNodeId(null)
|
||||
setDragOverNodeId(null)
|
||||
setDragPosition('inside')
|
||||
},
|
||||
[onMoveNode, dragPosition]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedNodeId(null)
|
||||
setDragOverNodeId(null)
|
||||
setDragPosition('inside')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
draggedNodeId,
|
||||
dragOverNodeId,
|
||||
dragPosition,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleDragEnd
|
||||
}
|
||||
}
|
||||
94
src/renderer/src/pages/notes/hooks/useNotesEditing.ts
Normal file
94
src/renderer/src/pages/notes/hooks/useNotesEditing.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useInPlaceEdit } from '@renderer/hooks/useInPlaceEdit'
|
||||
import { fetchNoteSummary } from '@renderer/services/ApiService'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const logger = loggerService.withContext('UseNotesEditing')
|
||||
|
||||
interface UseNotesEditingProps {
|
||||
onRenameNode: (nodeId: string, newName: string) => void
|
||||
}
|
||||
|
||||
export const useNotesEditing = ({ onRenameNode }: UseNotesEditingProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [renamingNodeIds, setRenamingNodeIds] = useState<Set<string>>(new Set())
|
||||
const [newlyRenamedNodeIds, setNewlyRenamedNodeIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const inPlaceEdit = useInPlaceEdit({
|
||||
onSave: (newName: string) => {
|
||||
if (editingNodeId && newName) {
|
||||
onRenameNode(editingNodeId, newName)
|
||||
window.toast.success(t('common.saved'))
|
||||
logger.debug(`Renamed node ${editingNodeId} to "${newName}"`)
|
||||
}
|
||||
setEditingNodeId(null)
|
||||
},
|
||||
onCancel: () => {
|
||||
setEditingNodeId(null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleStartEdit = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
setEditingNodeId(node.id)
|
||||
inPlaceEdit.startEdit(node.name)
|
||||
},
|
||||
[inPlaceEdit]
|
||||
)
|
||||
|
||||
const handleAutoRename = useCallback(
|
||||
async (note: NotesTreeNode) => {
|
||||
if (note.type !== 'file') return
|
||||
|
||||
setRenamingNodeIds((prev) => new Set(prev).add(note.id))
|
||||
try {
|
||||
const content = await window.api.file.readExternal(note.externalPath)
|
||||
if (!content || content.trim().length === 0) {
|
||||
window.toast.warning(t('notes.auto_rename.empty_note'))
|
||||
return
|
||||
}
|
||||
|
||||
const summaryText = await fetchNoteSummary({ content })
|
||||
if (summaryText) {
|
||||
onRenameNode(note.id, summaryText)
|
||||
window.toast.success(t('notes.auto_rename.success'))
|
||||
} else {
|
||||
window.toast.error(t('notes.auto_rename.failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast.error(t('notes.auto_rename.failed'))
|
||||
logger.error(`Failed to auto-rename note: ${error}`)
|
||||
} finally {
|
||||
setRenamingNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.id)
|
||||
return next
|
||||
})
|
||||
|
||||
setNewlyRenamedNodeIds((prev) => new Set(prev).add(note.id))
|
||||
|
||||
setTimeout(() => {
|
||||
setNewlyRenamedNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(note.id)
|
||||
return next
|
||||
})
|
||||
}, 700)
|
||||
}
|
||||
},
|
||||
[onRenameNode, t]
|
||||
)
|
||||
|
||||
return {
|
||||
editingNodeId,
|
||||
renamingNodeIds,
|
||||
newlyRenamedNodeIds,
|
||||
inPlaceEdit,
|
||||
handleStartEdit,
|
||||
handleAutoRename,
|
||||
setEditingNodeId
|
||||
}
|
||||
}
|
||||
112
src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
Normal file
112
src/renderer/src/pages/notes/hooks/useNotesFileUpload.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface UseNotesFileUploadProps {
|
||||
onUploadFiles: (files: File[]) => void
|
||||
setIsDragOverSidebar: (isDragOver: boolean) => void
|
||||
}
|
||||
|
||||
export const useNotesFileUpload = ({ onUploadFiles, setIsDragOverSidebar }: UseNotesFileUploadProps) => {
|
||||
const handleDropFiles = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOverSidebar(false)
|
||||
|
||||
// 处理文件夹拖拽:从 dataTransfer.items 获取完整文件路径信息
|
||||
const items = Array.from(e.dataTransfer.items)
|
||||
const files: File[] = []
|
||||
|
||||
const processEntry = async (entry: FileSystemEntry, path: string = '') => {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
return new Promise<void>((resolve) => {
|
||||
fileEntry.file((file) => {
|
||||
// 手动设置 webkitRelativePath 以保持文件夹结构
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: path + file.name,
|
||||
writable: false
|
||||
})
|
||||
files.push(file)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const reader = dirEntry.createReader()
|
||||
return new Promise<void>((resolve) => {
|
||||
reader.readEntries(async (entries) => {
|
||||
const promises = entries.map((subEntry) => processEntry(subEntry, path + entry.name + '/'))
|
||||
await Promise.all(promises)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果支持 DataTransferItem API(文件夹拖拽)
|
||||
if (items.length > 0 && items[0].webkitGetAsEntry()) {
|
||||
const promises = items.map((item) => {
|
||||
const entry = item.webkitGetAsEntry()
|
||||
return entry ? processEntry(entry) : Promise.resolve()
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
if (files.length > 0) {
|
||||
onUploadFiles(files)
|
||||
}
|
||||
} else {
|
||||
const regularFiles = Array.from(e.dataTransfer.files)
|
||||
if (regularFiles.length > 0) {
|
||||
onUploadFiles(regularFiles)
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUploadFiles, setIsDragOverSidebar]
|
||||
)
|
||||
|
||||
const handleSelectFiles = useCallback(() => {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.multiple = true
|
||||
fileInput.accept = '.md,.markdown'
|
||||
fileInput.webkitdirectory = false
|
||||
|
||||
fileInput.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const selectedFiles = Array.from(target.files)
|
||||
onUploadFiles(selectedFiles)
|
||||
}
|
||||
fileInput.remove()
|
||||
}
|
||||
|
||||
fileInput.click()
|
||||
}, [onUploadFiles])
|
||||
|
||||
const handleSelectFolder = useCallback(() => {
|
||||
const folderInput = document.createElement('input')
|
||||
folderInput.type = 'file'
|
||||
// @ts-ignore - webkitdirectory is a non-standard attribute
|
||||
folderInput.webkitdirectory = true
|
||||
// @ts-ignore - directory is a non-standard attribute
|
||||
folderInput.directory = true
|
||||
folderInput.multiple = true
|
||||
|
||||
folderInput.onchange = (e) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
const selectedFiles = Array.from(target.files)
|
||||
onUploadFiles(selectedFiles)
|
||||
}
|
||||
folderInput.remove()
|
||||
}
|
||||
|
||||
folderInput.click()
|
||||
}, [onUploadFiles])
|
||||
|
||||
return {
|
||||
handleDropFiles,
|
||||
handleSelectFiles,
|
||||
handleSelectFolder
|
||||
}
|
||||
}
|
||||
263
src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
Normal file
263
src/renderer/src/pages/notes/hooks/useNotesMenu.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { DeleteIcon } from '@renderer/components/Icons'
|
||||
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
|
||||
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
|
||||
import type { RootState } from '@renderer/store'
|
||||
import type { NotesTreeNode } from '@renderer/types/note'
|
||||
import { exportNote } from '@renderer/utils/export'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
import { Edit3, FilePlus, FileSearch, Folder, FolderOpen, Sparkles, Star, StarOff, UploadIcon } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
const logger = loggerService.withContext('UseNotesMenu')
|
||||
|
||||
interface UseNotesMenuProps {
|
||||
renamingNodeIds: Set<string>
|
||||
onCreateNote: (name: string, targetFolderId?: string) => void
|
||||
onCreateFolder: (name: string, targetFolderId?: string) => void
|
||||
onRenameNode: (nodeId: string, newName: string) => void
|
||||
onToggleStar: (nodeId: string) => void
|
||||
onDeleteNode: (nodeId: string) => void
|
||||
onSelectNode: (node: NotesTreeNode) => void
|
||||
handleStartEdit: (node: NotesTreeNode) => void
|
||||
handleAutoRename: (node: NotesTreeNode) => void
|
||||
activeNode?: NotesTreeNode | null
|
||||
}
|
||||
|
||||
export const useNotesMenu = ({
|
||||
renamingNodeIds,
|
||||
onCreateNote,
|
||||
onCreateFolder,
|
||||
onToggleStar,
|
||||
onDeleteNode,
|
||||
onSelectNode,
|
||||
handleStartEdit,
|
||||
handleAutoRename,
|
||||
activeNode
|
||||
}: UseNotesMenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { bases } = useKnowledgeBases()
|
||||
const exportMenuOptions = useSelector((state: RootState) => state.settings.exportMenuOptions)
|
||||
|
||||
const handleExportKnowledge = useCallback(
|
||||
async (note: NotesTreeNode) => {
|
||||
try {
|
||||
if (bases.length === 0) {
|
||||
window.toast.warning(t('chat.save.knowledge.empty.no_knowledge_base'))
|
||||
return
|
||||
}
|
||||
|
||||
const result = await SaveToKnowledgePopup.showForNote(note)
|
||||
|
||||
if (result?.success) {
|
||||
window.toast.success(t('notes.export_success', { count: result.savedCount }))
|
||||
}
|
||||
} catch (error) {
|
||||
window.toast.error(t('notes.export_failed'))
|
||||
logger.error(`Failed to export note to knowledge base: ${error}`)
|
||||
}
|
||||
},
|
||||
[bases.length, t]
|
||||
)
|
||||
|
||||
const handleImageAction = useCallback(
|
||||
async (node: NotesTreeNode, platform: 'copyImage' | 'exportImage') => {
|
||||
try {
|
||||
if (activeNode?.id !== node.id) {
|
||||
onSelectNode(node)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
await exportNote({ node, platform })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${platform === 'copyImage' ? 'copy' : 'export'} as image:`, error as Error)
|
||||
window.toast.error(t('common.copy_failed'))
|
||||
}
|
||||
},
|
||||
[activeNode, onSelectNode, t]
|
||||
)
|
||||
|
||||
const handleDeleteNodeWrapper = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
const confirmText =
|
||||
node.type === 'folder'
|
||||
? t('notes.delete_folder_confirm', { name: node.name })
|
||||
: t('notes.delete_note_confirm', { name: node.name })
|
||||
|
||||
window.modal.confirm({
|
||||
title: t('notes.delete'),
|
||||
content: confirmText,
|
||||
centered: true,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
onDeleteNode(node.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
[onDeleteNode, t]
|
||||
)
|
||||
|
||||
const getMenuItems = useCallback(
|
||||
(node: NotesTreeNode) => {
|
||||
const baseMenuItems: MenuProps['items'] = []
|
||||
|
||||
// only show auto rename for file for now
|
||||
if (node.type !== 'folder') {
|
||||
baseMenuItems.push({
|
||||
label: t('notes.auto_rename.label'),
|
||||
key: 'auto-rename',
|
||||
icon: <Sparkles size={14} />,
|
||||
disabled: renamingNodeIds.has(node.id),
|
||||
onClick: () => {
|
||||
handleAutoRename(node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (node.type === 'folder') {
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: t('notes.new_note'),
|
||||
key: 'new_note',
|
||||
icon: <FilePlus size={14} />,
|
||||
onClick: () => {
|
||||
onCreateNote(t('notes.untitled_note'), node.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.new_folder'),
|
||||
key: 'new_folder',
|
||||
icon: <Folder size={14} />,
|
||||
onClick: () => {
|
||||
onCreateFolder(t('notes.untitled_folder'), node.id)
|
||||
}
|
||||
},
|
||||
{ type: 'divider' }
|
||||
)
|
||||
}
|
||||
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: t('notes.rename'),
|
||||
key: 'rename',
|
||||
icon: <Edit3 size={14} />,
|
||||
onClick: () => {
|
||||
handleStartEdit(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.open_outside'),
|
||||
key: 'open_outside',
|
||||
icon: <FolderOpen size={14} />,
|
||||
onClick: () => {
|
||||
window.api.openPath(node.externalPath)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (node.type !== 'folder') {
|
||||
baseMenuItems.push(
|
||||
{
|
||||
label: node.isStarred ? t('notes.unstar') : t('notes.star'),
|
||||
key: 'star',
|
||||
icon: node.isStarred ? <StarOff size={14} /> : <Star size={14} />,
|
||||
onClick: () => {
|
||||
onToggleStar(node.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('notes.export_knowledge'),
|
||||
key: 'export_knowledge',
|
||||
icon: <FileSearch size={14} />,
|
||||
onClick: () => {
|
||||
handleExportKnowledge(node)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
icon: <UploadIcon size={14} />,
|
||||
children: [
|
||||
exportMenuOptions.image && {
|
||||
label: t('chat.topics.copy.image'),
|
||||
key: 'copy-image',
|
||||
onClick: () => handleImageAction(node, 'copyImage')
|
||||
},
|
||||
exportMenuOptions.image && {
|
||||
label: t('chat.topics.export.image'),
|
||||
key: 'export-image',
|
||||
onClick: () => handleImageAction(node, 'exportImage')
|
||||
},
|
||||
exportMenuOptions.markdown && {
|
||||
label: t('chat.topics.export.md.label'),
|
||||
key: 'markdown',
|
||||
onClick: () => exportNote({ node, platform: 'markdown' })
|
||||
},
|
||||
exportMenuOptions.docx && {
|
||||
label: t('chat.topics.export.word'),
|
||||
key: 'word',
|
||||
onClick: () => exportNote({ node, platform: 'docx' })
|
||||
},
|
||||
exportMenuOptions.notion && {
|
||||
label: t('chat.topics.export.notion'),
|
||||
key: 'notion',
|
||||
onClick: () => exportNote({ node, platform: 'notion' })
|
||||
},
|
||||
exportMenuOptions.yuque && {
|
||||
label: t('chat.topics.export.yuque'),
|
||||
key: 'yuque',
|
||||
onClick: () => exportNote({ node, platform: 'yuque' })
|
||||
},
|
||||
exportMenuOptions.obsidian && {
|
||||
label: t('chat.topics.export.obsidian'),
|
||||
key: 'obsidian',
|
||||
onClick: () => exportNote({ node, platform: 'obsidian' })
|
||||
},
|
||||
exportMenuOptions.joplin && {
|
||||
label: t('chat.topics.export.joplin'),
|
||||
key: 'joplin',
|
||||
onClick: () => exportNote({ node, platform: 'joplin' })
|
||||
},
|
||||
exportMenuOptions.siyuan && {
|
||||
label: t('chat.topics.export.siyuan'),
|
||||
key: 'siyuan',
|
||||
onClick: () => exportNote({ node, platform: 'siyuan' })
|
||||
}
|
||||
].filter(Boolean) as ItemType<MenuItemType>[]
|
||||
}
|
||||
)
|
||||
}
|
||||
baseMenuItems.push(
|
||||
{ type: 'divider' },
|
||||
{
|
||||
label: t('notes.delete'),
|
||||
danger: true,
|
||||
key: 'delete',
|
||||
icon: <DeleteIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => {
|
||||
handleDeleteNodeWrapper(node)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return baseMenuItems
|
||||
},
|
||||
[
|
||||
t,
|
||||
handleStartEdit,
|
||||
onToggleStar,
|
||||
handleExportKnowledge,
|
||||
handleImageAction,
|
||||
handleDeleteNodeWrapper,
|
||||
renamingNodeIds,
|
||||
handleAutoRename,
|
||||
exportMenuOptions,
|
||||
onCreateNote,
|
||||
onCreateFolder
|
||||
]
|
||||
)
|
||||
|
||||
return { getMenuItems }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'emoji-picker-element'
|
||||
|
||||
import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
|
||||
@@ -83,6 +83,68 @@ export async function renameNode(node: NotesTreeNode, newName: string): Promise<
|
||||
}
|
||||
|
||||
export async function uploadNotes(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const totalFiles = files.length
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles: 0,
|
||||
skippedFiles: 0,
|
||||
fileCount: 0,
|
||||
folderCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Get file paths from File objects
|
||||
// For browser File objects from drag-and-drop, we need to use FileReader to save temporarily
|
||||
// However, for directory uploads, the files already have paths
|
||||
const filePaths: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
// @ts-ignore - webkitRelativePath exists on File objects from directory uploads
|
||||
if (file.path) {
|
||||
// @ts-ignore - Electron File objects have .path property
|
||||
filePaths.push(file.path)
|
||||
} else {
|
||||
// For browser File API, we'd need to use FileReader and create temp files
|
||||
// For now, fall back to the old method for these cases
|
||||
logger.warn('File without path detected, using fallback method')
|
||||
return uploadNotesLegacy(files, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Pause file watcher to prevent N refresh events
|
||||
await window.api.file.pauseFileWatcher()
|
||||
|
||||
try {
|
||||
// Use the new optimized batch upload API that runs in Main process
|
||||
const result = await window.api.file.batchUploadMarkdown(filePaths, basePath)
|
||||
|
||||
return {
|
||||
uploadedNodes: [],
|
||||
totalFiles,
|
||||
skippedFiles: result.skippedFiles,
|
||||
fileCount: result.fileCount,
|
||||
folderCount: result.folderCount
|
||||
}
|
||||
} finally {
|
||||
// Resume watcher and trigger single refresh
|
||||
await window.api.file.resumeFileWatcher()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Batch upload failed, falling back to legacy method:', error as Error)
|
||||
// Fall back to old method if new method fails
|
||||
return uploadNotesLegacy(files, targetPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy upload method using Renderer process
|
||||
* Kept as fallback for browser File API files without paths
|
||||
*/
|
||||
async function uploadNotesLegacy(files: File[], targetPath: string): Promise<UploadResult> {
|
||||
const basePath = normalizePath(targetPath)
|
||||
const markdownFiles = filterMarkdown(files)
|
||||
const skippedFiles = files.length - markdownFiles.length
|
||||
@@ -101,18 +163,37 @@ export async function uploadNotes(files: File[], targetPath: string): Promise<Up
|
||||
await createFolders(folders)
|
||||
|
||||
let fileCount = 0
|
||||
const BATCH_SIZE = 5 // Process 5 files concurrently to balance performance and responsiveness
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const { dir, name } = resolveFileTarget(file, basePath)
|
||||
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||
// Process files in batches to avoid blocking the UI thread
|
||||
for (let i = 0; i < markdownFiles.length; i += BATCH_SIZE) {
|
||||
const batch = markdownFiles.slice(i, i + BATCH_SIZE)
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
await window.api.file.write(finalPath, content)
|
||||
fileCount += 1
|
||||
} catch (error) {
|
||||
logger.error('Failed to write uploaded file:', error as Error)
|
||||
// Process current batch in parallel
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (file) => {
|
||||
const { dir, name } = resolveFileTarget(file, basePath)
|
||||
const { safeName } = await window.api.file.checkFileName(dir, name, true)
|
||||
const finalPath = `${dir}/${safeName}${MARKDOWN_EXT}`
|
||||
|
||||
const content = await file.text()
|
||||
await window.api.file.write(finalPath, content)
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
// Count successful uploads
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
fileCount += 1
|
||||
} else {
|
||||
logger.error('Failed to write uploaded file:', result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
// Yield to the event loop between batches to keep UI responsive
|
||||
if (i + BATCH_SIZE < markdownFiles.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -585,11 +585,9 @@ const fetchAndProcessAgentResponseImpl = async (
|
||||
return
|
||||
}
|
||||
|
||||
// Only mark as cleared if there was a previous session ID (not initial assignment)
|
||||
sessionWasCleared = !!latestAgentSessionId
|
||||
|
||||
latestAgentSessionId = sessionId
|
||||
agentSession.agentSessionId = sessionId
|
||||
sessionWasCleared = true
|
||||
|
||||
logger.debug(`Agent session ID updated`, {
|
||||
topicId,
|
||||
|
||||
@@ -13,7 +13,7 @@ export type NotesSortType =
|
||||
export interface NotesTreeNode {
|
||||
id: string
|
||||
name: string // 不包含扩展名
|
||||
type: 'folder' | 'file'
|
||||
type: 'folder' | 'file' | 'hint'
|
||||
treePath: string // 相对路径
|
||||
externalPath: string // 绝对路径
|
||||
children?: NotesTreeNode[]
|
||||
|
||||
79
yarn.lock
79
yarn.lock
@@ -8082,15 +8082,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/better-sqlite3@npm:^7.6.12":
|
||||
version: 7.6.13
|
||||
resolution: "@types/better-sqlite3@npm:7.6.13"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10c0/c4336e7b92343eb0e988ded007c53fa9887b98a38d61175226e86124a1a2c28b1a4e3892873c5041e350b7bfa2901f85c82db1542c4f0eed1d3a899682c92106
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/body-parser@npm:*":
|
||||
version: 1.19.6
|
||||
resolution: "@types/body-parser@npm:1.19.6"
|
||||
@@ -8753,12 +8744,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:^19.0.4":
|
||||
version: 19.1.2
|
||||
resolution: "@types/react-dom@npm:19.1.2"
|
||||
"@types/react-dom@npm:^19.2.3":
|
||||
version: 19.2.3
|
||||
resolution: "@types/react-dom@npm:19.2.3"
|
||||
peerDependencies:
|
||||
"@types/react": ^19.0.0
|
||||
checksum: 10c0/100c341cacba9ec8ae1d47ee051072a3450e9573bf8eeb7262490e341cb246ea0f95a07a1f2077e61cf92648f812a0324c602fcd811bd87b7ce41db2811510cd
|
||||
"@types/react": ^19.2.0
|
||||
checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8798,12 +8789,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:^19.0.12":
|
||||
version: 19.1.2
|
||||
resolution: "@types/react@npm:19.1.2"
|
||||
"@types/react@npm:^19.2.6":
|
||||
version: 19.2.6
|
||||
resolution: "@types/react@npm:19.2.6"
|
||||
dependencies:
|
||||
csstype: "npm:^3.0.2"
|
||||
checksum: 10c0/76ffe71395c713d4adc3c759465012d3c956db00af35ab7c6d0d91bd07b274b7ce69caa0478c0760311587bd1e38c78ffc9688ebc629f2b266682a19d8750947
|
||||
csstype: "npm:^3.2.2"
|
||||
checksum: 10c0/23b1100f88662ce9f9e4fcca3a2b4ef9fff1ecde24ede2b2dcbd07731e48d6946fd7fd156cd133f5b25321694b0569cd9b8dd30b22c4e076d1cf4c8cdd9a75cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10009,7 +10000,6 @@ __metadata:
|
||||
"@tiptap/y-tiptap": "npm:^3.0.0"
|
||||
"@truto/turndown-plugin-gfm": "npm:^1.0.2"
|
||||
"@tryfabric/martian": "npm:^1.2.4"
|
||||
"@types/better-sqlite3": "npm:^7.6.12"
|
||||
"@types/cli-progress": "npm:^3"
|
||||
"@types/content-type": "npm:^1.1.9"
|
||||
"@types/cors": "npm:^2.8.19"
|
||||
@@ -10025,8 +10015,8 @@ __metadata:
|
||||
"@types/mime-types": "npm:^3"
|
||||
"@types/node": "npm:^22.17.1"
|
||||
"@types/pako": "npm:^1.0.2"
|
||||
"@types/react": "npm:^19.0.12"
|
||||
"@types/react-dom": "npm:^19.0.4"
|
||||
"@types/react": "npm:^19.2.6"
|
||||
"@types/react-dom": "npm:^19.2.3"
|
||||
"@types/react-infinite-scroll-component": "npm:^5.0.0"
|
||||
"@types/react-transition-group": "npm:^4.4.12"
|
||||
"@types/react-window": "npm:^1"
|
||||
@@ -10053,7 +10043,6 @@ __metadata:
|
||||
archiver: "npm:^7.0.1"
|
||||
async-mutex: "npm:^0.5.0"
|
||||
axios: "npm:^1.7.3"
|
||||
better-sqlite3: "npm:12.4.1"
|
||||
browser-image-compression: "npm:^2.0.2"
|
||||
chardet: "npm:^2.1.0"
|
||||
check-disk-space: "npm:3.4.0"
|
||||
@@ -10085,7 +10074,6 @@ __metadata:
|
||||
electron-window-state: "npm:^5.0.3"
|
||||
emittery: "npm:^1.0.3"
|
||||
emoji-picker-element: "npm:^1.22.1"
|
||||
emoji-picker-element-data: "npm:^1"
|
||||
epub: "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch"
|
||||
eslint: "npm:^9.22.0"
|
||||
eslint-plugin-import-zod: "npm:^1.2.0"
|
||||
@@ -10913,17 +10901,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"better-sqlite3@npm:12.4.1":
|
||||
version: 12.4.1
|
||||
resolution: "better-sqlite3@npm:12.4.1"
|
||||
dependencies:
|
||||
bindings: "npm:^1.5.0"
|
||||
node-gyp: "npm:latest"
|
||||
prebuild-install: "npm:^7.1.1"
|
||||
checksum: 10c0/88773a75d996b4171e5690a38459b05dc814a792701b224bd9909ee084dc0b4c64aaffbdbcf4bbbc6d4e247faf19e91b2a56cf4175d746d3bd9ff14764eb05aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bignumber.js@npm:^9.0.0":
|
||||
version: 9.2.1
|
||||
resolution: "bignumber.js@npm:9.2.1"
|
||||
@@ -10938,15 +10915,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bindings@npm:^1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "bindings@npm:1.5.0"
|
||||
dependencies:
|
||||
file-uri-to-path: "npm:1.0.0"
|
||||
checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"birecord@npm:^0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "birecord@npm:0.1.1"
|
||||
@@ -12315,6 +12283,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"csstype@npm:^3.2.2":
|
||||
version: 3.2.3
|
||||
resolution: "csstype@npm:3.2.3"
|
||||
checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"csv-parse@npm:^5.6.0":
|
||||
version: 5.6.0
|
||||
resolution: "csv-parse@npm:5.6.0"
|
||||
@@ -13687,13 +13662,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emoji-picker-element-data@npm:^1":
|
||||
version: 1.8.0
|
||||
resolution: "emoji-picker-element-data@npm:1.8.0"
|
||||
checksum: 10c0/c8976b636205a0cc90d2690859a1193add71a948dadf743962b47c338a4c3715768404d0ccbc02156608b44abf41f3e1d51756e06f1bbed9d164dd4cb1752103
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"emoji-picker-element@npm:^1.22.1":
|
||||
version: 1.26.3
|
||||
resolution: "emoji-picker-element@npm:1.26.3"
|
||||
@@ -14961,13 +14929,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"file-uri-to-path@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "file-uri-to-path@npm:1.0.0"
|
||||
checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"filelist@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "filelist@npm:1.0.4"
|
||||
@@ -20698,7 +20659,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.2":
|
||||
"prebuild-install@npm:^7.1.2":
|
||||
version: 7.1.3
|
||||
resolution: "prebuild-install@npm:7.1.3"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user