Compare commits

..

7 Commits

Author SHA1 Message Date
beyondkmp
00754f3644 Merge branch 'main' into betterSqlite 2025-11-21 10:27:20 +08:00
SuYao
dcdd1bf852 refactor: replace renderToolContent function with ToolContent component for improved readability (#11300)
* refactor: replace renderToolContent function with ToolContent component for improved readability

* fix

* fix test
2025-11-21 09:55:46 +08:00
beyondkmp
36a9af3e6b Merge branch 'main' into betterSqlite 2025-11-21 09:16:36 +08:00
beyondkmp
a12b6bfeca feat: enable native language emoji search with CLDR data format (#11381)
* feat: add i18n support and local data to emoji picker

- Add emoji-picker-element-data package for offline-first emoji data
- Implement i18n translations for emoji picker UI (de, en, es, fr, ja, pt, ru, zh)
- Switch from CDN to local emoji data to improve performance and reliability
- Add locale mapping to match app language with emoji picker data
- Move emoji-picker-element import to EmojiPicker component for better encapsulation
- Use proper TypeScript types instead of 'any' for type safety

This improves user experience by providing localized emoji picker interface
and eliminating dependency on external CDN, ensuring the picker works offline.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: enable native language emoji search with CLDR data format

Switch from emojibase to CLDR format for emoji-picker-element data to support full multi-language search functionality. Users can now search for emojis in their native language (e.g., German users can search "Herz" for ❤️, Spanish users can search "corazón"). Also improves type safety by using the LanguageVarious type for locale mappings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 19:23:27 +08:00
beyondkmp
67e032344b Merge branch 'main' into betterSqlite 2025-11-20 15:17:57 +08:00
beyondkmp
59a8f3c47d add rebuild 2025-11-20 15:15:02 +08:00
beyondkmp
fadb436c7d feat: integrate better-sqlite3 for database management
- Replaced @libsql/client with better-sqlite3 for improved database handling.
- Updated database interactions in BaseService, MigrationService, and various repository classes to utilize better-sqlite3 methods.
- Added better-sqlite3 and its types to package.json and yarn.lock.
- Adjusted database connection logic and query execution to align with better-sqlite3 API.
2025-11-19 15:40:26 +08:00
53 changed files with 2072 additions and 2230 deletions

View File

@@ -77,7 +77,9 @@
"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"
"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"
},
"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",
@@ -86,6 +88,8 @@
"@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",
@@ -200,6 +204,7 @@
"@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",
@@ -215,8 +220,8 @@
"@types/mime-types": "^3",
"@types/node": "^22.17.1",
"@types/pako": "^1.0.2",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1",

View File

@@ -196,9 +196,6 @@ 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

View File

@@ -10,7 +10,7 @@ export type LoaderReturn = {
messageSource?: 'preprocess' | 'embedding' | 'validation'
}
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir' | 'refresh'
export type FileChangeEventType = 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir'
export type FileChangeEvent = {
eventType: FileChangeEventType

View File

@@ -104,12 +104,6 @@ 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 })

View File

@@ -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

View File

@@ -557,9 +557,6 @@ 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

View File

@@ -1605,164 +1605,6 @@ 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()

View File

@@ -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 { drizzle, type LibSQLDatabase } from 'drizzle-orm/libsql'
import Database from 'better-sqlite3'
import { type BetterSQLite3Database, drizzle } from 'drizzle-orm/better-sqlite3'
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: Client | null = null
protected static db: LibSQLDatabase<typeof schema> | null = null
protected static client: Database.Database | null = null
protected static db: BetterSQLite3Database<typeof schema> | null = null
protected static isInitialized = false
protected static initializationPromise: Promise<void> | null = null
protected jsonFields: string[] = [
@@ -116,9 +116,7 @@ export abstract class BaseService {
fs.mkdirSync(dbDir, { recursive: true })
}
BaseService.client = createClient({
url: `file:${dbPath}`
})
BaseService.client = new Database(dbPath)
BaseService.db = drizzle(BaseService.client, { schema })
@@ -165,12 +163,12 @@ export abstract class BaseService {
}
}
protected get database(): LibSQLDatabase<typeof schema> {
protected get database(): BetterSQLite3Database<typeof schema> {
this.ensureInitialized()
return BaseService.db!
}
protected get rawClient(): Client {
protected get rawClient(): Database.Database {
this.ensureInitialized()
return BaseService.client!
}

View File

@@ -1,7 +1,7 @@
import { type Client } from '@libsql/client'
import { loggerService } from '@logger'
import { getResourcePath } from '@main/utils'
import { type LibSQLDatabase } from 'drizzle-orm/libsql'
import type Database from 'better-sqlite3'
import { type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import fs from 'fs'
import path from 'path'
@@ -23,11 +23,11 @@ interface MigrationJournal {
}
export class MigrationService {
private db: LibSQLDatabase<typeof schema>
private client: Client
private db: BetterSQLite3Database<typeof schema>
private client: Database.Database
private migrationDir: string
constructor(db: LibSQLDatabase<typeof schema>, client: Client) {
constructor(db: BetterSQLite3Database<typeof schema>, client: Database.Database) {
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 table = await this.client.execute(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`)
return table.rows.length > 0
const rows = this.client.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='migrations'`).all()
return 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')
await this.client.executeMultiple(sqlContent)
this.client.exec(sqlContent)
// Record migration as applied (store journal idx as version for tracking)
const newMigration: NewMigration = {

View File

@@ -91,17 +91,18 @@ class AgentMessageRepository extends BaseService {
return tx ?? this.database
}
private async findExistingMessageRow(
private findExistingMessageRow(
writer: TxClient,
sessionId: string,
role: string,
messageId: string
): Promise<SessionMessageRow | null> {
const candidateRows: SessionMessageRow[] = await writer
): SessionMessageRow | null {
const candidateRows: SessionMessageRow[] = 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
@@ -119,12 +120,9 @@ class AgentMessageRepository extends BaseService {
return null
}
private async upsertMessage(
private upsertMessageSync(
params: PersistUserMessageParams | PersistAssistantMessageParams
): Promise<AgentSessionMessageEntity> {
await AgentMessageRepository.initialize()
this.ensureInitialized()
): AgentSessionMessageEntity {
const { sessionId, agentSessionId = '', payload, metadata, createdAt, tx } = params
if (!payload?.message?.role) {
@@ -140,13 +138,13 @@ class AgentMessageRepository extends BaseService {
const serializedPayload = this.serializeMessage(payload)
const serializedMetadata = this.serializeMetadata(metadata)
const existingRow = await this.findExistingMessageRow(writer, sessionId, payload.message.role, payload.message.id)
const existingRow = 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 || ''
await writer
writer
.update(sessionMessagesTable)
.set({
content: serializedPayload,
@@ -155,6 +153,7 @@ class AgentMessageRepository extends BaseService {
updated_at: now
})
.where(eq(sessionMessagesTable.id, existingRow.id))
.run()
return this.deserialize({
...existingRow,
@@ -175,11 +174,19 @@ class AgentMessageRepository extends BaseService {
updated_at: now
}
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
const [saved] = writer.insert(sessionMessagesTable).values(insertData).returning().all()
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 ?? '' })
}
@@ -194,11 +201,11 @@ class AgentMessageRepository extends BaseService {
const { sessionId, agentSessionId, user, assistant } = params
const result = await this.database.transaction(async (tx) => {
const result = this.database.transaction((tx) => {
const exchangeResult: PersistExchangeResult = {}
if (user?.payload) {
exchangeResult.userMessage = await this.persistUserMessage({
exchangeResult.userMessage = this.upsertMessageSync({
sessionId,
agentSessionId,
payload: user.payload,
@@ -209,7 +216,7 @@ class AgentMessageRepository extends BaseService {
}
if (assistant?.payload) {
exchangeResult.assistantMessage = await this.persistAssistantMessage({
exchangeResult.assistantMessage = this.upsertMessageSync({
sessionId,
agentSessionId,
payload: assistant.payload,

View File

@@ -24,7 +24,7 @@ export default defineConfig({
schema: './src/main/services/agents/database/schema/index.ts',
out: './resources/database/drizzle',
dbCredentials: {
url: `file:${resolvedDbPath}`
url: resolvedDbPath
},
verbose: true,
strict: true

View File

@@ -202,9 +202,9 @@ export class AgentService extends BaseService {
async deleteAgent(id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database.delete(agentsTable).where(eq(agentsTable.id, id))
const result = this.database.delete(agentsTable).where(eq(agentsTable.id, id)).run()
return result.rowsAffected > 0
return result.changes > 0
}
async agentExists(id: string): Promise<boolean> {

View File

@@ -148,11 +148,12 @@ export class SessionMessageService extends BaseService {
async deleteSessionMessage(sessionId: string, messageId: number): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
const result = this.database
.delete(sessionMessagesTable)
.where(and(eq(sessionMessagesTable.id, messageId), eq(sessionMessagesTable.session_id, sessionId)))
.run()
return result.rowsAffected > 0
return result.changes > 0
}
async createSessionMessage(

View File

@@ -270,11 +270,12 @@ export class SessionService extends BaseService {
async deleteSession(agentId: string, id: string): Promise<boolean> {
this.ensureInitialized()
const result = await this.database
const result = this.database
.delete(sessionsTable)
.where(and(eq(sessionsTable.id, id), eq(sessionsTable.agent_id, agentId)))
.run()
return result.rowsAffected > 0
return result.changes > 0
}
async sessionExists(agentId: string, id: string): Promise<boolean> {

View File

@@ -21,6 +21,11 @@ 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', () => {
@@ -188,6 +193,111 @@ 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][] = []
@@ -300,4 +410,87 @@ 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')
})
})

View File

@@ -153,6 +153,20 @@ 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
@@ -217,10 +231,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, input: unknown, providerMetadata?: ProviderMetadata): void {
completeToolBlock(toolCallId: string, toolName: string, input: unknown, providerMetadata?: ProviderMetadata): void {
const block = this.getToolBlockByRawId(toolCallId)
this.registerToolCall(toolCallId, {
toolName: block?.toolName ?? 'unknown',
toolName,
input,
providerMetadata
})

View File

@@ -414,23 +414,6 @@ 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', {

View File

@@ -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: sdkMessage })
logger.silly('Transforming SDKMessage', { message: JSON.stringify(sdkMessage) })
switch (sdkMessage.type) {
case 'assistant':
return handleAssistantMessage(sdkMessage, state)
@@ -186,14 +186,13 @@ function handleAssistantMessage(
for (const block of content) {
switch (block.type) {
case 'text':
if (!isStreamingActive) {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
case 'text': {
const sanitizedText = stripLocalCommandTags(block.text)
if (sanitizedText) {
textBlocks.push(sanitizedText)
}
break
}
case 'tool_use':
handleAssistantToolUse(block as ToolUseContent, providerMetadata, state, chunks)
break
@@ -203,7 +202,16 @@ function handleAssistantMessage(
}
}
if (!isStreamingActive && textBlocks.length > 0) {
if (textBlocks.length === 0) {
return chunks
}
const combinedText = textBlocks.join('')
if (!combinedText) {
return chunks
}
if (!isStreamingActive) {
const id = message.uuid?.toString() || generateMessageId()
state.beginStep()
chunks.push({
@@ -219,7 +227,7 @@ function handleAssistantMessage(
chunks.push({
type: 'text-delta',
id,
text: textBlocks.join(''),
text: combinedText,
providerMetadata
})
chunks.push({
@@ -230,7 +238,27 @@ function handleAssistantMessage(
return finalizeNonStreamingStep(message, state, chunks)
}
return 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)
}
/**
@@ -252,7 +280,7 @@ function handleAssistantToolUse(
providerExecuted: true,
providerMetadata
})
state.completeToolBlock(block.id, block.input, providerMetadata)
state.completeToolBlock(block.id, block.name, block.input, providerMetadata)
}
/**
@@ -459,6 +487,9 @@ function handleStreamEvent(
}
case 'message_stop': {
if (!state.hasActiveStep()) {
break
}
const pending = state.getPendingUsage()
chunks.push({
type: 'finish-step',

View File

@@ -220,10 +220,6 @@ 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') {

View File

@@ -1,35 +1,120 @@
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 ref = useRef<HTMLDivElement>(null)
const { i18n } = useTranslation()
const ref = useRef<Picker>(null)
const currentLocale = i18n.language as LanguageVarious
useEffect(() => {
polyfillCountryFlagEmojis('Twemoji Mozilla', TwemojiCountryFlagsWoff2)
}, [])
// Configure picker with i18n and dataSource
useEffect(() => {
const refValue = ref.current
const picker = ref.current
if (picker) {
picker.i18n = i18nMap[currentLocale] || en
picker.dataSource = dataSourceMap[currentLocale] || dataEN
picker.locale = localeMap[currentLocale] || 'en'
}
}, [currentLocale])
if (refValue) {
const handleEmojiClick = (event: any) => {
useEffect(() => {
const picker = ref.current
if (picker) {
const handleEmojiClick = (event: EmojiClickEvent) => {
event.stopPropagation()
onEmojiClick(event.detail.unicode || event.detail.emoji.unicode)
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)
}
// 添加事件监听器
refValue.addEventListener('emoji-click', handleEmojiClick)
picker.addEventListener('emoji-click', handleEmojiClick)
// 清理事件监听器
return () => {
refValue.removeEventListener('emoji-click', handleEmojiClick)
picker.removeEventListener('emoji-click', handleEmojiClick)
}
}
return

View File

@@ -140,11 +140,11 @@ describe('DynamicVirtualList', () => {
// Should call isSticky function during rendering
expect(isSticky).toHaveBeenCalled()
// Sticky items within visible range should have proper z-index but may be absolute until scrolled
// Should apply sticky styles to sticky items
const stickyItem = document.querySelector('[data-index="0"]') as HTMLElement
expect(stickyItem).toBeInTheDocument()
// When sticky item is in visible range, it gets z-index but may not be sticky yet
expect(stickyItem).toHaveStyle('z-index: 999')
expect(stickyItem).toHaveStyle('position: sticky')
expect(stickyItem).toHaveStyle('z-index: 1')
})
it('should apply absolute positioning to non-sticky items', () => {

View File

@@ -24,7 +24,7 @@ exports[`DynamicVirtualList > basic rendering > snapshot test 1`] = `
>
<div
data-index="0"
style="position: absolute; top: 0px; left: 0px; z-index: 0; pointer-events: auto; transform: translateY(0px); width: 100%;"
style="position: absolute; top: 0px; left: 0px; 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; z-index: 0; pointer-events: auto; transform: translateY(50px); width: 100%;"
style="position: absolute; top: 0px; left: 0px; 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; z-index: 0; pointer-events: auto; transform: translateY(100px); width: 100%;"
style="position: absolute; top: 0px; left: 0px; transform: translateY(100px); width: 100%;"
>
<div
data-testid="item-2"

View File

@@ -62,12 +62,6 @@ 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
*/
@@ -107,7 +101,6 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
size,
estimateSize,
isSticky,
getItemDepth,
rangeExtractor: customRangeExtractor,
itemContainerStyle,
scrollerStyle,
@@ -122,7 +115,7 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
const internalScrollerRef = useRef<HTMLDivElement>(null)
const scrollerRef = internalScrollerRef
const activeStickyIndexesRef = useRef<number[]>([])
const activeStickyIndexRef = useRef(0)
const stickyIndexes = useMemo(() => {
if (!isSticky) return []
@@ -131,54 +124,21 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
const internalStickyRangeExtractor = useCallback(
(range: Range) => {
const activeStickies: number[] = []
// 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
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)
}
if (newActiveStickyIndex !== activeStickyIndexRef.current) {
activeStickyIndexRef.current = newActiveStickyIndex
}
// 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)])
// Merge the active sticky index and the default range extractor
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
// Sort the set to maintain proper order
return [...next].sort((a, b) => a - b)
},
[stickyIndexes, getItemDepth]
[stickyIndexes]
)
const rangeExtractor = customRangeExtractor ?? (isSticky ? internalStickyRangeExtractor : undefined)
@@ -261,47 +221,14 @@ function DynamicVirtualList<T>(props: DynamicVirtualListProps<T>) {
}}>
{virtualItems.map((virtualItem) => {
const isItemSticky = stickyIndexes.includes(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 isItemActiveSticky = isItemSticky && activeStickyIndexRef.current === virtualItem.index
const style: React.CSSProperties = {
...itemContainerStyle,
position: isItemActiveSticky ? 'sticky' : 'absolute',
top: isItemActiveSticky ? stickyOffset : 0,
top: 0,
left: 0,
zIndex: isItemActiveSticky ? 1000 + (100 - activeStickyIndex) : isItemSticky ? 999 : 0,
pointerEvents: isCoveredBySticky ? 'none' : 'auto',
...(isItemActiveSticky && {
backgroundColor: 'var(--color-background)'
}),
zIndex: isItemSticky ? 1 : undefined,
...(horizontal
? {
transform: isItemActiveSticky ? undefined : `translateX(${virtualItem.start}px)`,

View File

@@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTimer } from './useTimer'
const logger = loggerService.withContext('useInPlaceEdit')
export interface UseInPlaceEditOptions {
onSave: ((value: string) => void) | ((value: string) => Promise<void>)
onCancel?: () => void
@@ -12,10 +12,14 @@ 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
inputProps: React.InputHTMLAttributes<HTMLInputElement> & { ref: React.RefObject<HTMLInputElement | null> }
handleKeyDown: (e: React.KeyboardEvent) => void
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleValueChange: (value: string) => void
}
/**
@@ -33,55 +37,58 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
const [isSaving, setIsSaving] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState('')
const originalValueRef = useRef('')
const [originalValue, setOriginalValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { setTimeoutTimer } = useTimer()
const startEdit = useCallback((initialValue: string) => {
setIsEditing(true)
setEditValue(initialValue)
originalValueRef.current = initialValue
}, [])
const startEdit = useCallback(
(initialValue: string) => {
setIsEditing(true)
setEditValue(initialValue)
setOriginalValue(initialValue)
useLayoutEffect(() => {
if (isEditing) {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
}
}, [autoSelectOnStart, isEditing])
setTimeoutTimer(
'startEdit',
() => {
inputRef.current?.focus()
if (autoSelectOnStart) {
inputRef.current?.select()
}
},
0
)
},
[autoSelectOnStart, setTimeoutTimer]
)
const saveEdit = useCallback(async () => {
if (isSaving) return
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue === originalValueRef.current) {
setIsEditing(false)
return
}
setIsSaving(true)
try {
await onSave(finalValue)
const finalValue = trimOnSave ? editValue.trim() : editValue
if (finalValue !== originalValue) {
await onSave(finalValue)
}
setIsEditing(false)
setEditValue('')
} catch (error) {
logger.error('Error saving in-place edit', { error })
setOriginalValue('')
} finally {
setIsSaving(false)
}
}, [isSaving, trimOnSave, editValue, onSave])
}, [isSaving, trimOnSave, editValue, originalValue, onSave])
const cancelEdit = useCallback(() => {
setIsEditing(false)
setEditValue('')
setOriginalValue('')
onCancel?.()
}, [onCancel])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing) return
if (e.key === 'Enter') {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
saveEdit()
} else if (e.key === 'Escape') {
@@ -97,29 +104,37 @@ export function useInPlaceEdit(options: UseInPlaceEditOptions): UseInPlaceEditRe
setEditValue(e.target.value)
}, [])
const handleBlur = useCallback(() => {
// 这里的逻辑需要注意:
// 如果点击了“取消”按钮,可能会先触发 Blur 保存。
// 通常 InPlaceEdit 的逻辑是 Blur 即 Save。
// 如果不想 Blur 保存,可以去掉这一行,或者判断 relatedTarget。
if (!isSaving) {
saveEdit()
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()
}
}
}, [saveEdit, isSaving])
if (isEditing) {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}
return
}, [isEditing, saveEdit])
return {
isEditing,
isSaving,
editValue,
inputRef,
startEdit,
saveEdit,
cancelEdit,
inputProps: {
ref: inputRef,
value: editValue,
onChange: handleInputChange,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
disabled: isSaving // 保存时禁用输入
}
handleKeyDown,
handleInputChange,
handleValueChange
}
}

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "New Folder",
"untitled_note": "Untitled Note",
"upload_failed": "Note upload failed",
"upload_files": "Upload Files",
"upload_folder": "Upload Folder",
"upload_success": "Note uploaded success",
"uploading_files": "Uploading {{count}} files..."
"upload_success": "Note uploaded success"
},
"notification": {
"assistant": "Assistant Response",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "新文件夹",
"untitled_note": "无标题笔记",
"upload_failed": "笔记上传失败",
"upload_files": "上传文件",
"upload_folder": "上传文件夹",
"upload_success": "笔记上传成功",
"uploading_files": "正在上传 {{count}} 个文件..."
"upload_success": "笔记上传成功"
},
"notification": {
"assistant": "助手响应",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "新資料夾",
"untitled_note": "無標題筆記",
"upload_failed": "筆記上傳失敗",
"upload_files": "[to be translated]:Upload Files",
"upload_folder": "[to be translated]:Upload Folder",
"upload_success": "筆記上傳成功",
"uploading_files": "正在上傳 {{count}} 個檔案..."
"upload_success": "筆記上傳成功"
},
"notification": {
"assistant": "助手回應",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "Neuer Ordner",
"untitled_note": "Unbenannte Notiz",
"upload_failed": "Notizen-Upload fehlgeschlagen",
"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..."
"upload_success": "Notizen erfolgreich hochgeladen"
},
"notification": {
"assistant": "Assistenten-Antwort",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "Νέος φάκελος",
"untitled_note": "σημείωση χωρίς τίτλο",
"upload_failed": "Η σημείωση δεν ανέβηκε",
"upload_files": "[to be translated]:Upload Files",
"upload_folder": "[to be translated]:Upload Folder",
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία",
"uploading_files": "[to be translated]:Uploading {{count}} files..."
"upload_success": "Οι σημειώσεις μεταφορτώθηκαν με επιτυχία"
},
"notification": {
"assistant": "Απάντηση Βοηθού",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "Nueva carpeta",
"untitled_note": "Nota sin título",
"upload_failed": "Error al cargar la nota",
"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..."
"upload_success": "Nota cargada con éxito"
},
"notification": {
"assistant": "Respuesta del asistente",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "nouveau dossier",
"untitled_note": "Note sans titre",
"upload_failed": "Échec du téléchargement de la note",
"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..."
"upload_success": "Note téléchargée avec succès"
},
"notification": {
"assistant": "Réponse de l'assistant",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "新ファイル夹",
"untitled_note": "無題のメモ",
"upload_failed": "ノートのアップロードに失敗しました",
"upload_files": "[to be translated]:Upload Files",
"upload_folder": "[to be translated]:Upload Folder",
"upload_success": "ノートのアップロードが成功しました",
"uploading_files": "[to be translated]:Uploading {{count}} files..."
"upload_success": "ノートのアップロードが成功しました"
},
"notification": {
"assistant": "助手回應",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "Nova pasta",
"untitled_note": "Nota sem título",
"upload_failed": "Falha ao carregar a nota",
"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..."
"upload_success": "Nota carregada com sucesso"
},
"notification": {
"assistant": "Resposta do assistente",

View File

@@ -2175,10 +2175,7 @@
"untitled_folder": "Новая папка",
"untitled_note": "Незаглавленная заметка",
"upload_failed": "Не удалось загрузить заметку",
"upload_files": "[to be translated]:Upload Files",
"upload_folder": "[to be translated]:Upload Folder",
"upload_success": "Заметка успешно загружена",
"uploading_files": "[to be translated]:Uploading {{count}} files..."
"upload_success": "Заметка успешно загружена"
},
"notification": {
"assistant": "Ответ ассистента",

View File

@@ -1,7 +1,6 @@
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'
@@ -16,6 +15,63 @@ 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
@@ -23,73 +79,8 @@ export function BashOutputTool({
input: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
// 解析 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 parsedOutput = parseBashOutput(output)
const statusConfig = getStatusConfig(parsedOutput)
const children = parsedOutput ? (
<div className="flex flex-col gap-4">

View File

@@ -1,12 +1,47 @@
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
@@ -14,50 +49,8 @@ export function ReadTool({
input: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 移除 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])
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
return {
key: AgentToolsType.Read,

View File

@@ -11,11 +11,24 @@ interface UnknownToolProps {
output?: unknown
}
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
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 }) => {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
@@ -34,58 +47,49 @@ export function UnknownToolRenderer({
}
}, [output, highlightCode])
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
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
}
const getToolDescription = () => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
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>
)}
{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()}
params={getToolDescription(toolName)}
/>
),
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>
)
children: <UnknownToolContent input={input} output={output} />
}
}

View File

@@ -6,8 +6,6 @@ import { Collapse } from 'antd'
// 导出所有类型
export * from './types'
import { useMemo } from 'react'
// 导入所有渲染器
import ToolPermissionRequestCard from '../ToolPermissionRequestCard'
import { BashOutputTool } from './BashOutputTool'
@@ -57,22 +55,19 @@ export function isValidAgentToolsType(toolName: unknown): toolName is AgentTools
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染函数
function renderToolContent(toolName: AgentToolsType, input: ToolInput, output?: ToolOutput) {
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { 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 })
// 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])
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'
}
}
return (
<Collapse
@@ -98,5 +93,7 @@ export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolRe
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
return renderToolContent(tool.name as AgentToolsType, args as ToolInput, response as ToolOutput)
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
}

View File

@@ -42,7 +42,7 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
const targetSession = useDeferredValue(_targetSession)
const dispatch = useAppDispatch()
const { isEditing, isSaving, startEdit, inputProps } = useInPlaceEdit({
const { isEditing, isSaving, editValue, inputRef, startEdit, handleKeyDown, handleValueChange } = useInPlaceEdit({
onSave: async (value) => {
if (value !== session.name) {
await updateSession({ id: session.id, name: value })
@@ -179,7 +179,14 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
{isFulfilled && !isActive && <FulfilledIndicator />}
<SessionNameContainer>
{isEditing ? (
<SessionEditInput {...inputProps} style={{ opacity: isSaving ? 0.5 : 1 }} />
<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 }}
/>
) : (
<>
<SessionName>

View File

@@ -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 { startEdit, isEditing, inputProps } = useInPlaceEdit({
const topicEdit = useInPlaceEdit({
onSave: (name: string) => {
const topic = assistant.topics.find((t) => t.id === editingTopicId)
if (topic && name !== topic.name) {
@@ -520,23 +520,29 @@ export const Topics: React.FC<Props> = ({ assistant: _assistant, activeTopic, se
<TopicListItem
onContextMenu={() => setTargetTopic(topic)}
className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')}
onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)}
onClick={editingTopicId === topic.id && topicEdit.isEditing ? undefined : () => onSwitchTopic(topic)}
style={{
borderRadius,
cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer'
cursor: editingTopicId === topic.id && topicEdit.isEditing ? 'default' : 'pointer'
}}>
{isPending(topic.id) && !isActive && <PendingIndicator />}
{isFulfilled(topic.id) && !isActive && <FulfilledIndicator />}
<TopicNameContainer>
{editingTopicId === topic.id && isEditing ? (
<TopicEditInput {...inputProps} onClick={(e) => e.stopPropagation()} />
{editingTopicId === topic.id && topicEdit.isEditing ? (
<TopicEditInput
ref={topicEdit.inputRef}
value={topicEdit.editValue}
onChange={topicEdit.handleInputChange}
onKeyDown={topicEdit.handleKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<TopicName
className={getTopicNameClassName()}
title={topicName}
onDoubleClick={() => {
setEditingTopicId(topic.id)
startEdit(topic.name)
topicEdit.startEdit(topic.name)
}}>
{topicName}
</TopicName>

View File

@@ -295,16 +295,6 @@ 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':
@@ -631,27 +621,7 @@ const NotesPage: FC = () => {
throw new Error('No folder path selected')
}
// 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
}
const result = await uploadNotes(files, targetFolderPath)
// 检查上传结果
if (result.fileCount === 0) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,498 +0,0 @@
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

View File

@@ -1,109 +0,0 @@
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
}

View File

@@ -1,101 +0,0 @@
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
}
}

View File

@@ -1,94 +0,0 @@
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
}
}

View File

@@ -1,112 +0,0 @@
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
}
}

View File

@@ -1,263 +0,0 @@
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 }
}

View File

@@ -1,5 +1,3 @@
import 'emoji-picker-element'
import { CheckOutlined, LoadingOutlined, RollbackOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import EmojiPicker from '@renderer/components/EmojiPicker'

View File

@@ -83,68 +83,6 @@ 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
@@ -163,37 +101,18 @@ async function uploadNotesLegacy(files: File[], targetPath: string): Promise<Upl
await createFolders(folders)
let fileCount = 0
const BATCH_SIZE = 5 // Process 5 files concurrently to balance performance and responsiveness
// 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)
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 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))
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)
}
}

View File

@@ -585,9 +585,11 @@ 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,

View File

@@ -13,7 +13,7 @@ export type NotesSortType =
export interface NotesTreeNode {
id: string
name: string // 不包含扩展名
type: 'folder' | 'file' | 'hint'
type: 'folder' | 'file'
treePath: string // 相对路径
externalPath: string // 绝对路径
children?: NotesTreeNode[]

View File

@@ -8082,6 +8082,15 @@ __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"
@@ -8744,12 +8753,12 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:^19.2.3":
version: 19.2.3
resolution: "@types/react-dom@npm:19.2.3"
"@types/react-dom@npm:^19.0.4":
version: 19.1.2
resolution: "@types/react-dom@npm:19.1.2"
peerDependencies:
"@types/react": ^19.2.0
checksum: 10c0/b486ebe0f4e2fb35e2e108df1d8fc0927ca5d6002d5771e8a739de11239fe62d0e207c50886185253c99eb9dedfeeb956ea7429e5ba17f6693c7acb4c02f8cd1
"@types/react": ^19.0.0
checksum: 10c0/100c341cacba9ec8ae1d47ee051072a3450e9573bf8eeb7262490e341cb246ea0f95a07a1f2077e61cf92648f812a0324c602fcd811bd87b7ce41db2811510cd
languageName: node
linkType: hard
@@ -8789,12 +8798,12 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:^19.2.6":
version: 19.2.6
resolution: "@types/react@npm:19.2.6"
"@types/react@npm:^19.0.12":
version: 19.1.2
resolution: "@types/react@npm:19.1.2"
dependencies:
csstype: "npm:^3.2.2"
checksum: 10c0/23b1100f88662ce9f9e4fcca3a2b4ef9fff1ecde24ede2b2dcbd07731e48d6946fd7fd156cd133f5b25321694b0569cd9b8dd30b22c4e076d1cf4c8cdd9a75cb
csstype: "npm:^3.0.2"
checksum: 10c0/76ffe71395c713d4adc3c759465012d3c956db00af35ab7c6d0d91bd07b274b7ce69caa0478c0760311587bd1e38c78ffc9688ebc629f2b266682a19d8750947
languageName: node
linkType: hard
@@ -10000,6 +10009,7 @@ __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"
@@ -10015,8 +10025,8 @@ __metadata:
"@types/mime-types": "npm:^3"
"@types/node": "npm:^22.17.1"
"@types/pako": "npm:^1.0.2"
"@types/react": "npm:^19.2.6"
"@types/react-dom": "npm:^19.2.3"
"@types/react": "npm:^19.0.12"
"@types/react-dom": "npm:^19.0.4"
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-transition-group": "npm:^4.4.12"
"@types/react-window": "npm:^1"
@@ -10043,6 +10053,7 @@ __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"
@@ -10074,6 +10085,7 @@ __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"
@@ -10901,6 +10913,17 @@ __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"
@@ -10915,6 +10938,15 @@ __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"
@@ -12283,13 +12315,6 @@ __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"
@@ -13662,6 +13687,13 @@ __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"
@@ -14929,6 +14961,13 @@ __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"
@@ -20659,7 +20698,7 @@ __metadata:
languageName: node
linkType: hard
"prebuild-install@npm:^7.1.2":
"prebuild-install@npm:^7.1.1, prebuild-install@npm:^7.1.2":
version: 7.1.3
resolution: "prebuild-install@npm:7.1.3"
dependencies: