From 55738b6134e435c1bb82f81125d9470ce61925cc Mon Sep 17 00:00:00 2001 From: dzshzx Date: Wed, 17 Jun 2026 14:46:07 +0800 Subject: [PATCH 1/9] feat(hub,web): import existing Claude Code sessions Mirror the Codex session importer for Claude Code: scan ~/.claude/projects transcripts, map user/assistant(text/thinking/tool_use)/tool_result records to Hapi imported messages, persist via the shared store/sync engine, and expose /api/claude/status|sessions|sync-session plus a web import dialog. Extract the flavor-agnostic import engine into transcriptImport.ts so Codex and Claude share session-target selection, dedupe, persistence and event emission (single source of truth); each flavor keeps only its scanner, parser and adapter. Codex behavior and routes are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- hub/src/web/routes/claudeDesktop.test.ts | 249 +++++ hub/src/web/routes/claudeDesktop.ts | 429 +++++++++ hub/src/web/routes/codexDesktop.ts | 902 ++---------------- hub/src/web/routes/transcriptImport.ts | 887 +++++++++++++++++ hub/src/web/server.ts | 6 + web/src/api/client.ts | 20 + .../ClaudeSessionSyncDialog.test.tsx | 127 +++ .../components/ClaudeSessionSyncDialog.tsx | 103 ++ web/src/components/CodexSessionSyncDialog.tsx | 232 +---- web/src/components/SessionImportPicker.tsx | 274 ++++++ web/src/lib/claudeImportedSessions.ts | 94 ++ web/src/lib/locales/en.ts | 22 + web/src/lib/locales/zh-CN.ts | 22 + web/src/router.tsx | 115 ++- web/src/types/api.ts | 37 + 15 files changed, 2484 insertions(+), 1035 deletions(-) create mode 100644 hub/src/web/routes/claudeDesktop.test.ts create mode 100644 hub/src/web/routes/claudeDesktop.ts create mode 100644 hub/src/web/routes/transcriptImport.ts create mode 100644 web/src/components/ClaudeSessionSyncDialog.test.tsx create mode 100644 web/src/components/ClaudeSessionSyncDialog.tsx create mode 100644 web/src/components/SessionImportPicker.tsx create mode 100644 web/src/lib/claudeImportedSessions.ts diff --git a/hub/src/web/routes/claudeDesktop.test.ts b/hub/src/web/routes/claudeDesktop.test.ts new file mode 100644 index 0000000000..e099dae272 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createClaudeDesktopRoutes, importSelectedClaudeSessions, listLocalClaudeSessions } from './claudeDesktop' + +const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + +// 中文注释:写一条“内容丰富”的 Claude transcript,覆盖 user 字符串 / assistant text+thinking+tool_use / user tool_result,并夹杂若干 sidecar。 +function createRichTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-project', cwd: string | null = '/home/user/project'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + // sidecar lines: must be skipped + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'attachment', sessionId, content: 'ignored' }, + // isMeta user line: skipped + { type: 'user', isMeta: true, sessionId, cwd, message: { role: 'user', content: 'ignored' } }, + // real user message (string content) + { type: 'user', sessionId, cwd, message: { role: 'user', content: 'hello claude' } }, + // assistant with thinking + text + tool_use + { + type: 'assistant', + sessionId, + cwd, + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'let me think' }, + { type: 'text', text: 'working on it' }, + { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { file_path: '/tmp/x' } } + ] + } + }, + // user line carrying a tool_result block + { + type: 'user', + sessionId, + cwd, + message: { + role: 'user', + content: [ + { tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'text', text: 'file contents' }] } + ] + } + }, + // assistant final text + { type: 'assistant', sessionId, cwd, message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }, + // more sidecar + { type: 'ai-title', title: 'some title' } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createSidecarOnlyTranscript(claudeHome: string, sessionId: string, encodedCwd = '-home-user-empty'): void { + const projectDir = join(claudeHome, 'projects', encodedCwd) + mkdirSync(projectDir, { recursive: true }) + const transcriptPath = join(projectDir, `${sessionId}.jsonl`) + const lines: unknown[] = [ + { type: 'last-prompt', prompt: 'ignored' }, + { type: 'mode', mode: 'default' }, + { type: 'ai-title', title: 'no real conversation' }, + { type: 'user', isMeta: true, sessionId, message: { role: 'user', content: 'ignored' } } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createRoutesApp(namespace: string, store: Store): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createClaudeDesktopRoutes({ + store, + getSyncEngine: () => null + })) + return app +} + +describe('Claude Desktop import routes', () => { + afterEach(() => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + } + }) + + it('maps user/assistant text, thinking, tool_use and tool_result, skipping sidecar lines', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-test-')) + const store = new Store(':memory:') + const sessionId = '11111111-1111-4111-8111-111111111111' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const result = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + expect(session.metadata).toMatchObject({ + flavor: 'claude', + claudeSessionId: sessionId, + path: '/home/user/project', + lifecycleState: 'imported' + }) + + const messages = store.messages.getAllMessages(session.id) + // user(string), thinking, text, tool_use, tool_result, final text => 6 + expect(messages).toHaveLength(6) + + expect(messages[0].content).toEqual({ + role: 'user', + content: { type: 'text', text: 'hello claude' }, + meta: { sentFrom: 'cli' } + }) + expect((messages[1].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'reasoning', + message: 'let me think' + }) + expect((messages[2].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'working on it' + }) + expect((messages[3].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'tool-call', + name: 'Read', + callId: 'toolu_1', + input: { file_path: '/tmp/x' } + }) + expect((messages[4].content as { content: { data: { type: string; callId: string } } }).content.data).toMatchObject({ + type: 'tool-call-result', + callId: 'toolu_1' + }) + expect((messages[5].content as { content: { data: unknown } }).content.data).toMatchObject({ + type: 'message', + message: 'done' + }) + expect(messages[2].content).toMatchObject({ + content: { type: AGENT_MESSAGE_PAYLOAD_TYPE } + }) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('is idempotent: re-importing the same session adds no duplicate session or messages', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-idem-test-')) + const store = new Store(':memory:') + const sessionId = '22222222-2222-4222-8222-222222222222' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const first = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(first.success).toBe(true) + + const sessionsAfterFirst = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterFirst).toHaveLength(1) + const messagesAfterFirst = store.messages.getAllMessages(sessionsAfterFirst[0].id).length + + const second = await importSelectedClaudeSessions({ + claudeSessionIds: [sessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(second.success).toBe(true) + + const sessionsAfterSecond = store.sessions.getSessionsByNamespace('default') + expect(sessionsAfterSecond).toHaveLength(1) + expect(sessionsAfterSecond[0].id).toBe(sessionsAfterFirst[0].id) + const messagesAfterSecond = store.messages.getAllMessages(sessionsAfterSecond[0].id).length + expect(messagesAfterSecond).toBe(messagesAfterFirst) + } finally { + store.close() + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('filters out empty / sidecar-only sessions from listing', () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-empty-test-')) + const sessionId = '33333333-3333-4333-8333-333333333333' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createSidecarOnlyTranscript(claudeHome, sessionId) + const sessions = listLocalClaudeSessions() + expect(sessions).toHaveLength(0) + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) + + it('lists real sessions and rejects non-default namespace', async () => { + const claudeHome = mkdtempSync(join(tmpdir(), 'hapi-claude-home-route-test-')) + const sessionId = '44444444-4444-4444-8444-444444444444' + process.env.CLAUDE_CONFIG_DIR = claudeHome + + try { + createRichTranscript(claudeHome, sessionId) + + const defaultStore = new Store(':memory:') + try { + const defaultApp = createRoutesApp('default', defaultStore) + const response = await defaultApp.request('/api/claude/sessions') + expect(response.status).toBe(200) + const body = await response.json() as { success: boolean; sessions: { id: string }[] } + expect(body.success).toBe(true) + expect(body.sessions.map((s) => s.id)).toContain(sessionId) + } finally { + defaultStore.close() + } + + const teamStore = new Store(':memory:') + try { + const teamApp = createRoutesApp('team-a', teamStore) + const denied = await teamApp.request('/api/claude/sessions') + expect(denied.status).toBe(403) + } finally { + teamStore.close() + } + } finally { + rmSync(claudeHome, { recursive: true, force: true }) + } + }) +}) diff --git a/hub/src/web/routes/claudeDesktop.ts b/hub/src/web/routes/claudeDesktop.ts new file mode 100644 index 0000000000..b0da452174 --- /dev/null +++ b/hub/src/web/routes/claudeDesktop.ts @@ -0,0 +1,429 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { isAbsolute, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' +import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + expandHomePath, + getDirectImportRouteContext, + importSelectedSessions, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' + +type ClaudeStatusResponse = { + success: true + claudeProjectsAvailable: boolean +} + +type ClaudeLocalSessionsResponse = { + success: true + sessions: LocalSessionSummary[] +} + +const CLAUDE_SESSION_ID_KEY = 'claudeSessionId' +const CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Claude transcript import is not available outside the default namespace' + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +function getClaudeHome(): string { + // 中文注释:与 codex 的 getCodexHome 对称;优先 CLAUDE_CONFIG_DIR,否则回退 ~/.claude。 + const configured = process.env.CLAUDE_CONFIG_DIR?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : join(homedir(), '.claude') +} + +function getClaudeProjectRoots(): string[] { + return [join(getClaudeHome(), 'projects')] +} + +function decodeProjectDirName(dirName: string): string | null { + // 中文注释:Claude 把 cwd 里的路径分隔符编码成 '-'(如 /home/ubuntu → -home-ubuntu)。 + // 由于含 '-' 的真实路径无法可靠还原,cwd 仍以 transcript 行内的 cwd 字段为准,这里只作回退用途。 + if (!dirName) return null + const decoded = dirName.replace(/-/g, '/') + return decoded.startsWith('/') ? decoded : `/${decoded}` +} + +function extractClaudeBlockText(value: unknown): string { + if (typeof value === 'string') { + return value.trim() + } + if (Array.isArray(value)) { + return value + .map((item) => { + const record = asRecord(item) + if (record?.type === 'text' && typeof record.text === 'string') return record.text + return null + }) + .filter((part): part is string => Boolean(part)) + .join(' ') + .trim() + } + return '' +} + +function isMetaUserRecord(record: Record): boolean { + // 中文注释:Claude 会写入本地命令/环境提示等 isMeta 用户行,这些不是真实对话内容,跳过。 + return record.isMeta === true +} + +function getClaudeFirstUserMessage(lines: string[]): string | null { + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record || record.type !== 'user' || isMetaUserRecord(record)) continue + const message = asRecord(record.message) + const text = extractClaudeBlockText(message?.content) + if (text) { + return text + } + } + return null +} + +function readClaudeFields(lines: string[]): { sessionId: string | null; cwd: string | null; cliVersion: string | null } { + let sessionId: string | null = null + let cwd: string | null = null + let cliVersion: string | null = null + for (const line of lines) { + if (sessionId && cwd && cliVersion) break + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + if (!sessionId && typeof record.sessionId === 'string') sessionId = record.sessionId + if (!cwd && typeof record.cwd === 'string') cwd = record.cwd + if (!cliVersion && typeof record.version === 'string') cliVersion = record.version + } + return { sessionId, cwd, cliVersion } +} + +function getClaudeSessionTitle(cwd: string | null, sessionId: string, firstUserMessage: string | null): string { + if (firstUserMessage) { + return truncateText(firstUserMessage, 80) + } + if (cwd) { + const parts = cwd.split(/[\\/]+/).filter(Boolean) + if (parts.length > 0) { + return parts[parts.length - 1] + } + } + return sessionId.slice(0, 8) +} + +function parseClaudeLocalSession(filePath: string, dirName: string): LocalSessionSummary | null { + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + if (lines.length === 0) { + return null + } + + const { sessionId: inlineSessionId, cwd: inlineCwd, cliVersion } = readClaudeFields(lines) + const fileSessionId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.jsonl$/i, '') ?? null + const sessionId = fileSessionId || inlineSessionId + if (!sessionId) { + return null + } + + const cwd = inlineCwd ?? decodeProjectDirName(dirName) + const firstUserMessage = getClaudeFirstUserMessage(lines) + + let modifiedAt = Date.now() + try { + modifiedAt = statSync(filePath).mtimeMs + } catch { + // Fall back to current time if stat fails during a concurrent file change. + } + + return { + id: sessionId, + title: getClaudeSessionTitle(cwd, sessionId, firstUserMessage), + lastUserMessage: firstUserMessage ? truncateText(firstUserMessage, 140) : null, + cwd, + file: filePath, + modifiedAt, + originator: 'claude_code', + cliVersion + } +} + +function listLocalClaudeSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { + const deduped = new Map() + + for (const root of getClaudeProjectRoots()) { + if (!existsSync(root)) continue + let projectDirs + try { + projectDirs = readdirSync(root, { withFileTypes: true }) + } catch { + continue + } + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue + const projectPath = join(root, projectDir.name) + let entries + try { + entries = readdirSync(projectPath, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.jsonl')) continue + const filePath = join(projectPath, entry.name) + const session = parseClaudeLocalSession(filePath, projectDir.name) + if (!session) continue + // 中文注释:仅含 sidecar、没有任何可导入对话的会话不进入列表,避免给用户展示空壳会话。 + if (!parseClaudeTranscriptImportData(session)) continue + const previous = deduped.get(session.id) + if (!previous || previous.modifiedAt < session.modifiedAt) { + deduped.set(session.id, session) + } + } + } + } + + const sorted = Array.from(deduped.values()).sort((a, b) => b.modifiedAt - a.modifiedAt) + if (sorted.length > limit) { + // 中文注释:不静默截断;超过扫描上限时记录被截断的数量,方便排查“为什么少了会话”。 + console.warn(`[claude-import] listLocalClaudeSessions truncated ${sorted.length - limit} session(s) beyond limit=${limit}`) + } + return sorted.slice(0, limit) +} + +function convertClaudeRecordToImportedMessage(record: Record): ImportedMessageContent[] { + // 中文注释:一行 Claude 记录可能含多块(text/thinking/tool_use/tool_result),故返回数组而非单条。 + const type = asString(record.type) + const message = asRecord(record.message) + if (!type || !message) { + return [] + } + + const content = message.content + const results: ImportedMessageContent[] = [] + + if (type === 'user') { + if (isMetaUserRecord(record)) { + return [] + } + if (typeof content === 'string') { + const text = content.trim() + return text ? [buildImportedUserMessage(text)] : [] + } + if (Array.isArray(content)) { + const userTextParts: string[] = [] + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) userTextParts.push(text) + } else if (blockType === 'tool_result') { + const callId = asString(block.tool_use_id) + if (callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call-result', + callId, + output: block.content + })) + } + } + } + if (userTextParts.length > 0) { + // 中文注释:把用户文本拼成一条 user message,置于 tool_result 之前以保留视觉顺序。 + results.unshift(buildImportedUserMessage(userTextParts.join('\n'))) + } + return results + } + return [] + } + + if (type === 'assistant') { + if (!Array.isArray(content)) { + return [] + } + for (const item of content) { + const block = asRecord(item) + if (!block) continue + const blockType = asString(block.type) + if (blockType === 'text' && typeof block.text === 'string') { + const text = block.text.trim() + if (text) { + results.push(buildImportedAgentMessage({ type: 'message', message: text })) + } + } else if (blockType === 'thinking' && typeof block.thinking === 'string') { + const thinking = block.thinking.trim() + if (thinking) { + results.push(buildImportedAgentMessage({ type: 'reasoning', message: thinking })) + } + } else if (blockType === 'tool_use') { + const name = asString(block.name) + const callId = asString(block.id) + if (name && callId) { + results.push(buildImportedAgentMessage({ + type: 'tool-call', + name, + callId, + input: block.input + })) + } + } + } + return results + } + + // 中文注释:其余 sidecar 类型(last-prompt/mode/agent-setting/permission-mode/attachment/system/ + // file-history-snapshot/ai-title/agent-name/queue-operation 等)一律安全跳过。 + return [] +} + +function parseClaudeTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { + let content: string + try { + content = readFileSync(summary.file, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + const messages: ImportedMessageContent[] = [] + + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + const record = asRecord(parsed) + if (!record) continue + for (const message of convertClaudeRecordToImportedMessage(record)) { + messages.push(message) + } + } + + if (messages.length === 0) { + return null + } + + return { + ...summary, + messages + } +} + +// 中文注释:claudeAdapter 把 Claude 专属扫描/解析封装成通用 ImporterAdapter,落库/同步/去重复用 transcriptImport。 +const claudeAdapter: ImporterAdapter = { + flavor: 'claude', + sessionIdKey: CLAUDE_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalClaudeSessions(limit), + parseTranscript: (summary) => parseClaudeTranscriptImportData(summary) +} + +export async function importSelectedClaudeSessions(options: { + claudeSessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + return importSelectedSessions({ + adapter: claudeAdapter, + sessionIds: options.claudeSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) +} + +export { + listLocalClaudeSessions, + parseClaudeTranscriptImportData, + convertClaudeRecordToImportedMessage +} + +export function createClaudeDesktopRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/claude/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CLAUDE_TRANSCRIPT_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/claude/status', (c) => { + const available = getClaudeProjectRoots().some((root) => existsSync(root)) + return c.json({ + success: true, + claudeProjectsAvailable: available + } satisfies ClaudeStatusResponse) + }) + + app.get('/claude/sessions', (c) => { + return c.json({ + success: true, + sessions: listLocalClaudeSessions() + } satisfies ClaudeLocalSessionsResponse) + }) + + app.post('/claude/sync-session', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error, + cwd: workspace + }) + } + + // 中文注释:直接读取本地 Claude transcript 写入 Hapi store,复用与 Codex 相同的落库/同步/去重引擎。 + const result = await importSelectedClaudeSessions({ + claudeSessionIds: parsed.sessionIds, + store: options.store, + namespace: c.get('namespace'), + getSyncEngine: options.getSyncEngine + }) + return c.json(result) + }) + + return app +} diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index d5723f4025..8248dfcbb6 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -1,46 +1,40 @@ -import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { spawn, spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { homedir, hostname, platform } from 'node:os' -import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { homedir } from 'node:os' import { Hono } from 'hono' -import type { Machine, SyncEngine } from '../../sync/syncEngine' -import type { Store, StoredMessage } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store } from '../../store' import type { WebAppEnv } from '../middleware/auth' +import { + type ImportedMessageContent, + type ImporterAdapter, + type LocalSessionSummary, + type ScriptLaunchResponse, + type TranscriptImportData, + DEFAULT_SESSION_SCAN_LIMIT, + NO_SYNC_SESSION_SELECTED_ERROR, + appendScriptLog, + asRecord, + asString, + buildImportedAgentMessage, + buildImportedUserMessage, + getDirectImportRouteContext, + importSelectedSessions, + listDuplicateSessionGroups, + mergeDuplicateSessionGroups, + parseSyncSessionRequest, + truncateText +} from './transcriptImport' type ScriptLogKind = 'sync' | 'restart' -const DIRECT_IMPORT_COMMAND = 'direct-import' const RESTART_SCRIPT_ENV_NAME = 'HAPI_CODEX_RESTART_SCRIPT' const RESTART_SCRIPT_DEFAULT_FILE = 'Restart-CodexDesktop.ps1' const RESTART_SCRIPT_ARGS = ['-Apply'] const RESTART_SCRIPT_MESSAGE = 'Codex Desktop restart script started' -type ScriptLaunchResponse = { - success: true - message: string - pid: number - command: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} | { - success: false - error: string - script?: string - cwd: string - output?: string - codexDesktopRunning?: boolean - codexClientAvailable?: boolean - syncedCount?: number - sessionIds?: string[] -} - type CodexDesktopStatus = { running: boolean clientAvailable: boolean @@ -52,68 +46,14 @@ type CodexDesktopStatusResponse = { codexClientAvailable: boolean } -type CodexLocalSessionSummary = { - id: string - title: string - lastUserMessage?: string | null - cwd?: string | null - file: string - modifiedAt: number - originator?: string | null - cliVersion?: string | null -} - type CodexLocalSessionsResponse = { success: true - sessions: CodexLocalSessionSummary[] -} - -type CodexImportedMessageContent = { - role: 'user' - content: { - type: 'text' - text: string - } - meta: { - sentFrom: 'cli' - } -} | { - role: 'agent' - content: { - type: typeof AGENT_MESSAGE_PAYLOAD_TYPE - data: unknown - } - meta: { - sentFrom: 'cli' - } -} - -type CodexTranscriptImportData = CodexLocalSessionSummary & { - messages: CodexImportedMessageContent[] -} - -type ImportCandidate = { - sessionId: string - active: boolean - updatedAt: number - metadata: Record | null -} - -type ImportTargetSelection = { - sessionId: string | null - comparablePrefixCount: number -} - -type SyncSessionRequestParseResult = { - sessionIds: string[] - error?: string + sessions: LocalSessionSummary[] } type CodexDuplicateSessionGroup = { codexSessionId: string hapiSessionIds: string[] - canonicalSessionId?: string - removedSessionIds?: string[] } type CodexDuplicateSessionsResponse = { @@ -126,24 +66,18 @@ type CodexDuplicateSessionsResponse = { type CodexMergeDuplicateSessionsResponse = { success: true - merged: CodexDuplicateSessionGroup[] + merged: { codexSessionId: string; hapiSessionIds: string[]; canonicalSessionId?: string; removedSessionIds?: string[] }[] mergedCount: number } | { success: false error: string } -type DuplicateSessionGroupCandidate = { - codexSessionId: string - sessions: ImportCandidate[] -} - +const CODEX_SESSION_ID_KEY = 'codexSessionId' const CODEX_DESKTOP_NOT_FOUND_ERROR = '尝试重启codex客户端失败,未安装/找不到codex客户端' const SCRIPT_TIMEOUT_ERROR = '执行超时' -const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的 Codex 会话' const CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Codex transcript import is not available outside the default namespace' const DEFAULT_SCRIPT_TIMEOUT_MS = 60_000 -const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 500 function resolveLocalPath(pathValue: string): string { return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) @@ -187,11 +121,6 @@ function getWorkspace(scriptPath: string): string { return configured ? resolveLocalPath(configured) : dirname(scriptPath) } -function getDirectImportWorkspace(): string { - const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() - return configured ? resolveLocalPath(configured) : process.cwd() -} - function expandHomePath(pathValue: string): string { return pathValue.replace(/^~(?=$|[\\/])/, homedir()) } @@ -228,16 +157,6 @@ function collectJsonlFiles(root: string, files: string[]): void { } } -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -} - -function asString(value: unknown): string | null { - return typeof value === 'string' && value.length > 0 ? value : null -} - function extractCodexText(value: unknown): string { if (typeof value === 'string') { return value.trim() @@ -268,10 +187,6 @@ function extractCodexText(value: unknown): string { return '' } -function truncateText(value: string, maxLength: number): string { - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value -} - function shouldIgnoreSyntheticUserMessage(text: string): boolean { const normalized = text.trim() return normalized.startsWith('# AGENTS.md instructions') @@ -410,7 +325,7 @@ function isSubagentSource(value: unknown): boolean { return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false } -function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { +function parseCodexLocalSession(filePath: string): LocalSessionSummary | null { let content: string try { content = readFileSync(filePath, 'utf-8') @@ -493,13 +408,13 @@ function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | nu } } -function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): CodexLocalSessionSummary[] { +function listLocalCodexSessions(limit = DEFAULT_SESSION_SCAN_LIMIT): LocalSessionSummary[] { const files: string[] = [] for (const root of getCodexSessionRoots()) { collectJsonlFiles(root, files) } - const deduped = new Map() + const deduped = new Map() for (const filePath of files) { const session = parseCodexLocalSession(filePath) if (!session) continue @@ -514,33 +429,7 @@ function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Codex .slice(0, limit) } -function buildImportedUserMessage(text: string): CodexImportedMessageContent { - return { - role: 'user', - content: { - type: 'text', - text - }, - meta: { - sentFrom: 'cli' - } - } -} - -function buildImportedAgentMessage(data: unknown): CodexImportedMessageContent { - return { - role: 'agent', - content: { - type: AGENT_MESSAGE_PAYLOAD_TYPE, - data - }, - meta: { - sentFrom: 'cli' - } - } -} - -function convertCodexRecordToImportedMessage(record: Record): CodexImportedMessageContent | null { +function convertCodexRecordToImportedMessage(record: Record): ImportedMessageContent | null { const type = asString(record.type) const payload = asRecord(record.payload) if (!type || !payload) { @@ -639,7 +528,7 @@ function convertCodexRecordToImportedMessage(record: Record): C return null } -function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { +function parseCodexTranscriptImportData(summary: LocalSessionSummary): TranscriptImportData | null { let content: string try { content = readFileSync(summary.file, 'utf-8') @@ -648,7 +537,7 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } const lines = content.split(/\r?\n/).filter(Boolean) - const messages: CodexImportedMessageContent[] = [] + const messages: ImportedMessageContent[] = [] for (const line of lines) { let parsed: unknown @@ -672,431 +561,27 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code } } -function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { - let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') - if (normalized.length > 1) { - normalized = normalized.replace(/\/+$/, '') - } - return options?.caseInsensitive ? normalized.toLowerCase() : normalized +// 中文注释:codexAdapter 把 Codex 专属的扫描/解析封装成通用 ImporterAdapter,落库/同步/去重交给 transcriptImport 共享引擎。 +const codexAdapter: ImporterAdapter = { + flavor: 'codex', + sessionIdKey: CODEX_SESSION_ID_KEY, + listLocalSessions: (limit) => listLocalCodexSessions(limit), + parseTranscript: (summary) => parseCodexTranscriptImportData(summary) } -function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { - return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) -} - -function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { - if (!pathValue.trim() || !rootValue.trim()) { - return false - } - - const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) - const path = normalizeComparablePath(pathValue, { caseInsensitive }) - const root = normalizeComparablePath(rootValue, { caseInsensitive }) - if (!path || !root) { - return false - } - if (path === root) { - return true - } - if (root === '/') { - return path.startsWith('/') - } - return path.startsWith(`${root}/`) -} - -function machineOwnsCodexCwd(machine: Machine, cwd: string): boolean { - const workspaceRoots = machine.metadata?.workspaceRoots ?? [] - return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) -} - -function resolveImportMachineId( - cwd: string | null | undefined, - namespace: string, - engine: SyncEngine | null -): string | undefined { - if (!cwd || !engine) { - return undefined - } - - const matches = engine.getOnlineMachinesByNamespace(namespace) - .filter((machine) => machineOwnsCodexCwd(machine, cwd)) - const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) - return machineIds.length === 1 ? machineIds[0] : undefined -} - -function buildImportedSessionMetadata( - data: CodexTranscriptImportData, - existingMetadata?: Record | null, - resolvedMachineId?: string -): Record { - const now = Date.now() - const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) - const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) - const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() - const summaryText = data.lastUserMessage ?? data.title - const machineId = typeof existingMetadata?.machineId === 'string' - ? existingMetadata.machineId - : resolvedMachineId - - return { - ...(existingMetadata ?? {}), - path, - host, - os: osValue, - name: data.title, - summary: summaryText - ? { - text: summaryText, - updatedAt: now - } - : existingMetadata?.summary, - flavor: 'codex', - codexSessionId: data.id, - ...(machineId ? { machineId } : {}), - lifecycleState: typeof existingMetadata?.lifecycleState === 'string' - ? existingMetadata.lifecycleState - : 'imported', - lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' - ? existingMetadata.lifecycleStateSince - : now - } -} - -function stableSerialize(value: unknown): string { - if (value === null || value === undefined) { - return String(value) - } - if (typeof value === 'string') { - return JSON.stringify(value) - } - if (typeof value === 'number' || typeof value === 'boolean') { - return JSON.stringify(value) - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableSerialize(item)).join(',')}]` - } - if (typeof value === 'object') { - const record = value as Record - const keys = Object.keys(record).sort() - return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` - } - return JSON.stringify(value) -} - -function normalizeComparableAgentData(value: unknown): unknown { - const record = asRecord(value) - if (!record) { - return value - } - - const normalized = { ...record } - if ('id' in normalized) { - delete normalized.id - } - return normalized -} - -function normalizeComparableContent(content: unknown): string | null { - const record = asRecord(content) - if (!record) { - return null - } - - if (record.role === 'user') { - const body = asRecord(record.content) - if (body?.type !== 'text' || typeof body.text !== 'string') { - return null - } - return stableSerialize({ - role: 'user', - text: body.text - }) - } - - if (record.role === 'agent') { - const body = asRecord(record.content) - if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { - return null - } - return stableSerialize({ - role: 'agent', - data: normalizeComparableAgentData(body.data) - }) - } - - return null -} - -function getComparableStoredMessageKey(message: StoredMessage): string { - // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 - return normalizeComparableContent(message.content) ?? stableSerialize(message.content) -} - -function collectImportCandidates( - store: Store, - namespace: string, - getSyncEngine?: () => SyncEngine | null -): ImportCandidate[] { - const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] - if (engineSessions.length > 0) { - return engineSessions.map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) - } - - return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ - sessionId: session.id, - active: session.active, - updatedAt: session.updatedAt, - metadata: asRecord(session.metadata) - })) -} - -function selectImportTargetSession( - store: Store, - candidates: ImportCandidate[], - codexSessionId: string, - importedComparableMessages: string[] -): ImportTargetSelection { - const relatedCandidates = candidates - .filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId) - .sort((a, b) => b.updatedAt - a.updatedAt) - - if (relatedCandidates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - let bestSessionId: string | null = null - let bestPrefixCount = -1 - - for (const candidate of relatedCandidates) { - const comparableMessages = store.messages.getAllMessages(candidate.sessionId) - .map((message) => normalizeComparableContent(message.content)) - .filter((value): value is string => value !== null) - - if (comparableMessages.length > importedComparableMessages.length) { - continue - } - - let prefixMatches = true - for (let index = 0; index < comparableMessages.length; index += 1) { - if (comparableMessages[index] !== importedComparableMessages[index]) { - prefixMatches = false - break - } - } - - if (!prefixMatches) { - continue - } - - if (comparableMessages.length > bestPrefixCount) { - bestPrefixCount = comparableMessages.length - bestSessionId = candidate.sessionId - } - } - - return { - sessionId: bestSessionId, - comparablePrefixCount: Math.max(0, bestPrefixCount) - } -} - -function listDuplicateCodexSessionGroups( - store: Store, - namespace: string, - codexSessionIds: string[], - getSyncEngine?: () => SyncEngine | null -): DuplicateSessionGroupCandidate[] { - const requestedSessionIds = new Set(codexSessionIds) - if (requestedSessionIds.size === 0) { - return [] - } - - const groups = new Map() - for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { - const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string' - ? candidate.metadata.codexSessionId - : null - if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) { - continue - } - - const existing = groups.get(codexSessionId) - if (existing) { - existing.push(candidate) - } else { - groups.set(codexSessionId, [candidate]) - } - } - - return Array.from(groups.entries()) - .map(([codexSessionId, sessions]) => ({ - codexSessionId, - sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) - })) - .filter((group) => group.sessions.length > 1) -} - -async function mergeDuplicateCodexSessionGroups(options: { - store: Store - namespace: string +export async function importSelectedCodexSessions(options: { codexSessionIds: string[] - getSyncEngine?: () => SyncEngine | null -}): Promise { - const groups = listDuplicateCodexSessionGroups( - options.store, - options.namespace, - options.codexSessionIds, - options.getSyncEngine - ) - if (groups.length === 0) { - return { - success: true, - merged: [], - mergedCount: 0 - } - } - - const merged: CodexDuplicateSessionGroup[] = [] - for (const group of groups) { - const result = await mergeSingleDuplicateCodexSessionGroup({ - group, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - merged.push(result) - } - - return { - success: true, - merged, - mergedCount: merged.length - } -} - -async function mergeSingleDuplicateCodexSessionGroup(options: { - group: DuplicateSessionGroupCandidate store: Store namespace: string getSyncEngine?: () => SyncEngine | null -}): Promise { - const engine = options.getSyncEngine?.() ?? null - const sessionStates = options.group.sessions - .map((candidate) => ({ - ...candidate, - storedMessages: options.store.messages.getAllMessages(candidate.sessionId), - })) - .map((candidate) => ({ - ...candidate, - comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) - })) - .sort((a, b) => { - if (b.comparableKeys.length !== a.comparableKeys.length) { - return b.comparableKeys.length - a.comparableKeys.length - } - if (b.updatedAt !== a.updatedAt) { - return b.updatedAt - a.updatedAt - } - return a.sessionId.localeCompare(b.sessionId) - }) - - if (sessionStates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - - const canonical = sessionStates[0] - if (!canonical) { - throw new Error(`No duplicate Hapi session found for Codex thread: ${options.group.codexSessionId}`) - } - - const knownKeys = new Set(canonical.comparableKeys) - const removedSessionIds: string[] = [] - const appendedMessages: StoredMessage[] = [] - let latestActivity = canonical.updatedAt - - for (const source of sessionStates.slice(1)) { - latestActivity = Math.max(latestActivity, source.updatedAt) - for (const message of source.storedMessages) { - const comparableKey = getComparableStoredMessageKey(message) - if (knownKeys.has(comparableKey)) { - continue - } - - const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { - content: message.content, - createdAt: message.createdAt, - localId: message.localId, - invokedAt: message.invokedAt, - scheduledAt: message.scheduledAt - }) - knownKeys.add(comparableKey) - appendedMessages.push(copied) - latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) - } - - if (engine) { - await engine.deleteSession(source.sessionId) - } else { - const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) - if (!deleted) { - throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) - } - } - removedSessionIds.push(source.sessionId) - } - - if (appendedMessages.length > 0) { - emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) - } - - if (engine) { - engine.recordSessionActivity(canonical.sessionId, latestActivity) - // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 - engine.handleRealtimeEvent({ - type: 'session-updated', - sessionId: canonical.sessionId - }) - } else { - options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) - } - - return { - codexSessionId: options.group.codexSessionId, - hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), - canonicalSessionId: canonical.sessionId, - removedSessionIds - } -} - -function emitImportedMessageEvents( - engine: SyncEngine | null, - sessionId: string, - appendedMessages: StoredMessage[] -): void { - if (!engine) { - return - } - - // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 - for (const message of appendedMessages) { - engine.handleRealtimeEvent({ - type: 'message-received', - sessionId, - message: { - id: message.id, - seq: message.seq, - localId: message.localId ?? null, - content: message.content, - createdAt: message.createdAt, - invokedAt: message.invokedAt - } - }) - } +}): Promise { + return importSelectedSessions({ + adapter: codexAdapter, + sessionIds: options.codexSessionIds, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) } function getPathExts(): string[] { @@ -1262,15 +747,8 @@ function createLaunchArgs(scriptPath: string, workspace: string, scriptArgs: str ] } -function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { - try { - const logDir = join(workspace, 'logs') - mkdirSync(logDir, { recursive: true }) - const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` - appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') - } catch { - // Best-effort logging only; API response still carries the error. - } +function appendRestartScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + appendScriptLog(workspace, kind, message) } async function runPowerShellScript(scriptPath: string, workspace: string, scriptArgs: string[]): Promise<{ pid: number; command: string; output: string }> { @@ -1366,7 +844,7 @@ async function launchRestartScript(): Promise { const workspace = getWorkspace(scriptPath) if (!existsSync(scriptPath)) { - appendScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) return { success: false, error: `Script not found: ${scriptPath}`, @@ -1376,7 +854,7 @@ async function launchRestartScript(): Promise { } if (!existsSync(workspace)) { - appendScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) return { success: false, error: `Workspace not found: ${workspace}`, @@ -1388,7 +866,7 @@ async function launchRestartScript(): Promise { try { const launched = await runPowerShellScript(scriptPath, workspace, RESTART_SCRIPT_ARGS) const output = launched.output - appendScriptLog( + appendRestartScriptLog( workspace, 'restart', `SUCCESS: ${RESTART_SCRIPT_MESSAGE}; pid=${launched.pid}; command=${launched.command}; script=${scriptPath}${output ? `; output=${output}` : ''}` @@ -1404,7 +882,7 @@ async function launchRestartScript(): Promise { } } catch (error) { const message = error instanceof Error ? error.message : String(error) - appendScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) return { success: false, error: message, @@ -1414,251 +892,6 @@ async function launchRestartScript(): Promise { } } -function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { - // 中文注释:导入弹窗现在直接提交 Codex thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 - if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { - return { sessionIds: [] } - } - - const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds - if (!Array.isArray(rawSessionIds)) { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - - const sessionIds: string[] = [] - for (const value of rawSessionIds) { - if (typeof value !== 'string') { - return { sessionIds: [], error: 'Invalid sessionIds' } - } - const trimmed = value.trim() - if (trimmed) { - sessionIds.push(trimmed) - } - } - - // 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。 - return { sessionIds: Array.from(new Set(sessionIds)) } -} - -function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { - const output = results - .map((result, index) => { - // 中文注释:direct import 不再依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 - const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) - return detail ? `[${index + 1}] ${detail}` : '' - }) - .filter(Boolean) - .join('\n\n') - .trim() - return output || undefined -} - -function getDirectImportRouteContext(): { workspace: string } { - return { - workspace: getDirectImportWorkspace() - } -} - -function createImportErrorResponse( - codexSessionIds: string[], - error: string, - syncedCount = 0 -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${codexSessionIds.join(',') || '(none)'}`) - return { - success: false, - error, - cwd: workspace, - sessionIds: codexSessionIds, - syncedCount - } -} - -function createImportSuccessResponse( - codexSessionIds: string[], - results: ScriptLaunchResponse[] -): ScriptLaunchResponse { - const { workspace } = getDirectImportRouteContext() - appendScriptLog( - workspace, - 'sync', - `SUCCESS: imported ${results.length} Codex session(s); sessionIds=${codexSessionIds.join(',')}` - ) - return { - success: true, - message: `Imported ${results.length} Codex session(s) into Hapi`, - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: workspace, - output: combineSyncOutputs(results), - sessionIds: codexSessionIds, - syncedCount: results.length - } -} - -function importSingleCodexSession(options: { - codexSessionId: string - localSessionsById: Map - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): ScriptLaunchResponse { - const summary = options.localSessionsById.get(options.codexSessionId) - if (!summary) { - return { - ...createImportErrorResponse([options.codexSessionId], `Transcript not found for Codex session: ${options.codexSessionId}`), - output: `未找到对应的本地 transcript:${options.codexSessionId}` - } - } - - const transcript = parseCodexTranscriptImportData(summary) - if (!transcript) { - return { - ...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`), - output: `解析 transcript 失败:${summary.file}` - } - } - - if (transcript.messages.length === 0) { - return { - ...createImportErrorResponse([options.codexSessionId], `No importable conversation content found in transcript: ${summary.file}`), - output: `transcript 中没有可导入的会话内容:${summary.file}` - } - } - - const importedComparableMessages = transcript.messages - .map((message) => normalizeComparableContent(message)) - .filter((value): value is string => value !== null) - - try { - const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) - const target = selectImportTargetSession( - options.store, - candidates, - options.codexSessionId, - importedComparableMessages - ) - const engine = options.getSyncEngine?.() ?? null - const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null - const metadata = buildImportedSessionMetadata( - transcript, - asRecord(existingStored?.metadata), - resolveImportMachineId(transcript.cwd, options.namespace, engine) - ) - - let sessionId = existingStored?.id ?? null - let created = false - if (!sessionId) { - // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 - const createdSession = engine?.getOrCreateSession( - randomUUID(), - metadata, - {}, - options.namespace - ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) - sessionId = createdSession.id - created = true - } else if (existingStored) { - const updatedMetadata = options.store.sessions.updateSessionMetadata( - existingStored.id, - metadata, - existingStored.metadataVersion, - options.namespace - ) - if (updatedMetadata.result !== 'success') { - throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) - } - engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) - } - - if (!sessionId) { - throw new Error(`Failed to determine target Hapi session for Codex thread: ${options.codexSessionId}`) - } - - const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 - const messagesToAppend = transcript.messages.slice(comparablePrefixCount) - const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) - - // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 - const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() - if (engine) { - engine.recordSessionActivity(sessionId, latestMessageCreatedAt) - } else { - options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) - } - if (!created) { - emitImportedMessageEvents(engine, sessionId, appendedMessages) - } - - const output = [ - `Codex thread: ${options.codexSessionId}`, - `Hapi session: ${sessionId}`, - `Action: ${created ? 'created' : 'updated'}`, - `Appended messages: ${appendedMessages.length}` - ].join('\n') - - appendScriptLog( - getDirectImportRouteContext().workspace, - 'sync', - `SUCCESS: codexSessionId=${options.codexSessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` - ) - - return { - success: true, - message: created ? 'Codex session imported into a new Hapi session' : 'Codex session appended to existing Hapi session', - pid: 0, - command: DIRECT_IMPORT_COMMAND, - cwd: getDirectImportRouteContext().workspace, - output, - sessionIds: [options.codexSessionId], - syncedCount: 1 - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - ...createImportErrorResponse([options.codexSessionId], message), - output: `Codex thread: ${options.codexSessionId}\n${message}` - } - } -} - -export async function importSelectedCodexSessions(options: { - codexSessionIds: string[] - store: Store - namespace: string - getSyncEngine?: () => SyncEngine | null -}): Promise { - const codexSessionIds = options.codexSessionIds - if (codexSessionIds.length === 0) { - return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR) - } - - const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session])) - const results: ScriptLaunchResponse[] = [] - for (const codexSessionId of codexSessionIds) { - const result = importSingleCodexSession({ - codexSessionId, - localSessionsById, - store: options.store, - namespace: options.namespace, - getSyncEngine: options.getSyncEngine - }) - results.push(result) - - if (!result.success) { - return { - ...result, - sessionIds: codexSessionIds, - syncedCount: Math.max(0, results.length - 1), - output: combineSyncOutputs(results) ?? result.output - } - } - } - - return createImportSuccessResponse(codexSessionIds, results) -} - export function createCodexDesktopRoutes(options: { store: Store getSyncEngine: () => SyncEngine | null @@ -1739,13 +972,14 @@ export function createCodexDesktopRoutes(options: { } // 中文注释:这里只检查本次导入弹窗里勾选过的 codexSessionId;未选中的会话即使也有重复,也不参与本轮提示。 - const duplicates = listDuplicateCodexSessionGroups( + const duplicates = listDuplicateSessionGroups( options.store, c.get('namespace'), + CODEX_SESSION_ID_KEY, parsed.sessionIds, options.getSyncEngine ).map((group) => ({ - codexSessionId: group.codexSessionId, + codexSessionId: group.flavorSessionId, hapiSessionIds: group.sessions.map((session) => session.sessionId) })) @@ -1775,10 +1009,11 @@ export function createCodexDesktopRoutes(options: { const { workspace } = getDirectImportRouteContext() try { // 中文注释:真正执行合并时仍然只按这次选中的 codexSessionId 收口,防止顺手把别的会话历史也改掉。 - const result = await mergeDuplicateCodexSessionGroups({ + const result = await mergeDuplicateSessionGroups({ store: options.store, namespace: c.get('namespace'), - codexSessionIds: parsed.sessionIds, + sessionIdKey: CODEX_SESSION_ID_KEY, + flavorSessionIds: parsed.sessionIds, getSyncEngine: options.getSyncEngine }) appendScriptLog( @@ -1786,7 +1021,20 @@ export function createCodexDesktopRoutes(options: { 'sync', `SUCCESS: merged duplicate Hapi sessions for selected codexSessionIds=${parsed.sessionIds.join(',')}` ) - return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + if (!result.success) { + return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + } + // 中文注释:对外字段保持 codexSessionId 命名不变,避免破坏既有前端契约。 + return c.json({ + success: true, + merged: result.merged.map((group) => ({ + codexSessionId: group.sessionId, + hapiSessionIds: group.hapiSessionIds, + canonicalSessionId: group.canonicalSessionId, + removedSessionIds: group.removedSessionIds + })), + mergedCount: result.mergedCount + } satisfies CodexMergeDuplicateSessionsResponse) } catch (error) { const message = error instanceof Error ? error.message : String(error) appendScriptLog( @@ -1807,7 +1055,7 @@ export function createCodexDesktopRoutes(options: { const scriptPath = getRestartScriptPath() const workspace = getWorkspace(scriptPath) const error = CODEX_DESKTOP_NOT_FOUND_ERROR - appendScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) + appendRestartScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) return c.json({ success: false, error, diff --git a/hub/src/web/routes/transcriptImport.ts b/hub/src/web/routes/transcriptImport.ts new file mode 100644 index 0000000000..656c3fb46e --- /dev/null +++ b/hub/src/web/routes/transcriptImport.ts @@ -0,0 +1,887 @@ +import { appendFileSync, mkdirSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname, platform } from 'node:os' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import type { Machine, SyncEngine } from '../../sync/syncEngine' +import type { Store, StoredMessage } from '../../store' + +// 中文注释:本文件收敛 Codex / Claude transcript 导入共用的“落库 / 同步 / 去重 / 响应”逻辑。 +// 各 flavor 只需提供 ImporterAdapter(扫描路径 + 解析器 + flavor/metadata),不再复制粘贴第二份并行逻辑。 + +export type ScriptLogKind = 'sync' | 'restart' + +export type TranscriptFlavor = 'codex' | 'claude' + +export type ImportedMessageContent = { + role: 'user' + content: { + type: 'text' + text: string + } + meta: { + sentFrom: 'cli' + } +} | { + role: 'agent' + content: { + type: typeof AGENT_MESSAGE_PAYLOAD_TYPE + data: unknown + } + meta: { + sentFrom: 'cli' + } +} + +export type LocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +export type TranscriptImportData = LocalSessionSummary & { + messages: ImportedMessageContent[] +} + +export type ScriptLaunchResponse = { + success: true + message: string + pid: number + command: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} | { + success: false + error: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} + +export type ImportCandidate = { + sessionId: string + active: boolean + updatedAt: number + metadata: Record | null +} + +export type ImportTargetSelection = { + sessionId: string | null + comparablePrefixCount: number +} + +export type SyncSessionRequestParseResult = { + sessionIds: string[] + error?: string +} + +export type DuplicateSessionGroup = { + sessionId: string + hapiSessionIds: string[] + canonicalSessionId?: string + removedSessionIds?: string[] +} + +export type DuplicateSessionsResponse = { + success: true + duplicates: DuplicateSessionGroup[] +} | { + success: false + error: string +} + +export type MergeDuplicateSessionsResponse = { + success: true + merged: DuplicateSessionGroup[] + mergedCount: number +} | { + success: false + error: string +} + +type DuplicateSessionGroupCandidate = { + flavorSessionId: string + sessions: ImportCandidate[] +} + +// 中文注释:ImporterAdapter 是各 flavor 与通用引擎之间的唯一契约。 +// - sessionIdKey 决定去重/绑定时在 metadata 上读哪个键(codexSessionId / claudeSessionId)。 +// - listLocalSessions / parseTranscript 是各 flavor 专属的扫描与解析。 +export interface ImporterAdapter { + flavor: TranscriptFlavor + sessionIdKey: string + listLocalSessions(limit?: number): LocalSessionSummary[] + parseTranscript(summary: LocalSessionSummary): TranscriptImportData | null +} + +export const DIRECT_IMPORT_COMMAND = 'direct-import' +export const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的会话' +export const DEFAULT_SESSION_SCAN_LIMIT = 500 + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +export function expandHomePath(pathValue: string): string { + return pathValue.replace(/^~(?=$|[\\/])/, homedir()) +} + +export function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +export function buildImportedUserMessage(text: string): ImportedMessageContent { + return { + role: 'user', + content: { + type: 'text', + text + }, + meta: { + sentFrom: 'cli' + } + } +} + +export function buildImportedAgentMessage(data: unknown): ImportedMessageContent { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data + }, + meta: { + sentFrom: 'cli' + } + } +} + +function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string { + let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/') + if (normalized.length > 1) { + normalized = normalized.replace(/\/+$/, '') + } + return options?.caseInsensitive ? normalized.toLowerCase() : normalized +} + +function shouldCompareCaseInsensitive(...pathValues: string[]): boolean { + return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\')) +} + +function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean { + if (!pathValue.trim() || !rootValue.trim()) { + return false + } + + const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue) + const path = normalizeComparablePath(pathValue, { caseInsensitive }) + const root = normalizeComparablePath(rootValue, { caseInsensitive }) + if (!path || !root) { + return false + } + if (path === root) { + return true + } + if (root === '/') { + return path.startsWith('/') + } + return path.startsWith(`${root}/`) +} + +function machineOwnsCwd(machine: Machine, cwd: string): boolean { + const workspaceRoots = machine.metadata?.workspaceRoots ?? [] + return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot)) +} + +export function resolveImportMachineId( + cwd: string | null | undefined, + namespace: string, + engine: SyncEngine | null +): string | undefined { + if (!cwd || !engine) { + return undefined + } + + const matches = engine.getOnlineMachinesByNamespace(namespace) + .filter((machine) => machineOwnsCwd(machine, cwd)) + const machineIds = Array.from(new Set(matches.map((machine) => machine.id))) + return machineIds.length === 1 ? machineIds[0] : undefined +} + +export function buildImportedSessionMetadata( + data: TranscriptImportData, + flavor: TranscriptFlavor, + sessionIdKey: string, + existingMetadata?: Record | null, + resolvedMachineId?: string +): Record { + const now = Date.now() + const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) + const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) + const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() + const summaryText = data.lastUserMessage ?? data.title + const machineId = typeof existingMetadata?.machineId === 'string' + ? existingMetadata.machineId + : resolvedMachineId + + return { + ...(existingMetadata ?? {}), + path, + host, + os: osValue, + name: data.title, + summary: summaryText + ? { + text: summaryText, + updatedAt: now + } + : existingMetadata?.summary, + flavor, + [sessionIdKey]: data.id, + ...(machineId ? { machineId } : {}), + lifecycleState: typeof existingMetadata?.lifecycleState === 'string' + ? existingMetadata.lifecycleState + : 'imported', + lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' + ? existingMetadata.lifecycleStateSince + : now + } +} + +export function stableSerialize(value: unknown): string { + if (value === null || value === undefined) { + return String(value) + } + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + if (typeof value === 'object') { + const record = value as Record + const keys = Object.keys(record).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +function normalizeComparableAgentData(value: unknown): unknown { + const record = asRecord(value) + if (!record) { + return value + } + + const normalized = { ...record } + if ('id' in normalized) { + delete normalized.id + } + return normalized +} + +export function normalizeComparableContent(content: unknown): string | null { + const record = asRecord(content) + if (!record) { + return null + } + + if (record.role === 'user') { + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + return stableSerialize({ + role: 'user', + text: body.text + }) + } + + if (record.role === 'agent') { + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + return stableSerialize({ + role: 'agent', + data: normalizeComparableAgentData(body.data) + }) + } + + return null +} + +export function getComparableStoredMessageKey(message: StoredMessage): string { + // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 + return normalizeComparableContent(message.content) ?? stableSerialize(message.content) +} + +export function collectImportCandidates( + store: Store, + namespace: string, + getSyncEngine?: () => SyncEngine | null +): ImportCandidate[] { + const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] + if (engineSessions.length > 0) { + return engineSessions.map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) + } + + return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) +} + +export function selectImportTargetSession( + store: Store, + candidates: ImportCandidate[], + sessionIdKey: string, + flavorSessionId: string, + importedComparableMessages: string[] +): ImportTargetSelection { + const relatedCandidates = candidates + .filter((candidate) => candidate.metadata?.[sessionIdKey] === flavorSessionId) + .sort((a, b) => b.updatedAt - a.updatedAt) + + if (relatedCandidates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + let bestSessionId: string | null = null + let bestPrefixCount = -1 + + for (const candidate of relatedCandidates) { + const comparableMessages = store.messages.getAllMessages(candidate.sessionId) + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + if (comparableMessages.length > importedComparableMessages.length) { + continue + } + + let prefixMatches = true + for (let index = 0; index < comparableMessages.length; index += 1) { + if (comparableMessages[index] !== importedComparableMessages[index]) { + prefixMatches = false + break + } + } + + if (!prefixMatches) { + continue + } + + if (comparableMessages.length > bestPrefixCount) { + bestPrefixCount = comparableMessages.length + bestSessionId = candidate.sessionId + } + } + + return { + sessionId: bestSessionId, + comparablePrefixCount: Math.max(0, bestPrefixCount) + } +} + +export function listDuplicateSessionGroups( + store: Store, + namespace: string, + sessionIdKey: string, + flavorSessionIds: string[], + getSyncEngine?: () => SyncEngine | null +): DuplicateSessionGroupCandidate[] { + const requestedSessionIds = new Set(flavorSessionIds) + if (requestedSessionIds.size === 0) { + return [] + } + + const groups = new Map() + for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { + const flavorSessionId = typeof candidate.metadata?.[sessionIdKey] === 'string' + ? candidate.metadata[sessionIdKey] as string + : null + if (!flavorSessionId || !requestedSessionIds.has(flavorSessionId)) { + continue + } + + const existing = groups.get(flavorSessionId) + if (existing) { + existing.push(candidate) + } else { + groups.set(flavorSessionId, [candidate]) + } + } + + return Array.from(groups.entries()) + .map(([flavorSessionId, sessions]) => ({ + flavorSessionId, + sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) + })) + .filter((group) => group.sessions.length > 1) +} + +export async function mergeDuplicateSessionGroups(options: { + store: Store + namespace: string + sessionIdKey: string + flavorSessionIds: string[] + getSyncEngine?: () => SyncEngine | null +}): Promise { + const groups = listDuplicateSessionGroups( + options.store, + options.namespace, + options.sessionIdKey, + options.flavorSessionIds, + options.getSyncEngine + ) + if (groups.length === 0) { + return { + success: true, + merged: [], + mergedCount: 0 + } + } + + const merged: DuplicateSessionGroup[] = [] + for (const group of groups) { + const result = await mergeSingleDuplicateSessionGroup({ + group, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + merged.push(result) + } + + return { + success: true, + merged, + mergedCount: merged.length + } +} + +async function mergeSingleDuplicateSessionGroup(options: { + group: DuplicateSessionGroupCandidate + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const engine = options.getSyncEngine?.() ?? null + const sessionStates = options.group.sessions + .map((candidate) => ({ + ...candidate, + storedMessages: options.store.messages.getAllMessages(candidate.sessionId), + })) + .map((candidate) => ({ + ...candidate, + comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) + })) + .sort((a, b) => { + if (b.comparableKeys.length !== a.comparableKeys.length) { + return b.comparableKeys.length - a.comparableKeys.length + } + if (b.updatedAt !== a.updatedAt) { + return b.updatedAt - a.updatedAt + } + return a.sessionId.localeCompare(b.sessionId) + }) + + if (sessionStates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + const canonical = sessionStates[0] + if (!canonical) { + throw new Error(`No duplicate Hapi session found for thread: ${options.group.flavorSessionId}`) + } + + const knownKeys = new Set(canonical.comparableKeys) + const removedSessionIds: string[] = [] + const appendedMessages: StoredMessage[] = [] + let latestActivity = canonical.updatedAt + + for (const source of sessionStates.slice(1)) { + latestActivity = Math.max(latestActivity, source.updatedAt) + for (const message of source.storedMessages) { + const comparableKey = getComparableStoredMessageKey(message) + if (knownKeys.has(comparableKey)) { + continue + } + + const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { + content: message.content, + createdAt: message.createdAt, + localId: message.localId, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + }) + knownKeys.add(comparableKey) + appendedMessages.push(copied) + latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) + } + + if (engine) { + await engine.deleteSession(source.sessionId) + } else { + const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) + if (!deleted) { + throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) + } + } + removedSessionIds.push(source.sessionId) + } + + if (appendedMessages.length > 0) { + emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) + } + + if (engine) { + engine.recordSessionActivity(canonical.sessionId, latestActivity) + // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 + engine.handleRealtimeEvent({ + type: 'session-updated', + sessionId: canonical.sessionId + }) + } else { + options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) + } + + return { + sessionId: options.group.flavorSessionId, + hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), + canonicalSessionId: canonical.sessionId, + removedSessionIds + } +} + +export function emitImportedMessageEvents( + engine: SyncEngine | null, + sessionId: string, + appendedMessages: StoredMessage[] +): void { + if (!engine) { + return + } + + // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 + for (const message of appendedMessages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId ?? null, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +export function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { + // 中文注释:导入弹窗直接提交 thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 + if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { + return { sessionIds: [] } + } + + const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds + if (!Array.isArray(rawSessionIds)) { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + + const sessionIds: string[] = [] + for (const value of rawSessionIds) { + if (typeof value !== 'string') { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + const trimmed = value.trim() + if (trimmed) { + sessionIds.push(trimmed) + } + } + + // 中文注释:前端允许多选,这里按 thread 去重,避免重复导入同一条本地 transcript。 + return { sessionIds: Array.from(new Set(sessionIds)) } +} + +function getDirectImportWorkspace(): string { + const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : process.cwd() +} + +export function getDirectImportRouteContext(): { workspace: string } { + return { + workspace: getDirectImportWorkspace() + } +} + +export function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + try { + const logDir = join(workspace, 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` + appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') + } catch { + // Best-effort logging only; API response still carries the error. + } +} + +export function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { + const output = results + .map((result, index) => { + // 中文注释:direct import 不依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 + const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) + return detail ? `[${index + 1}] ${detail}` : '' + }) + .filter(Boolean) + .join('\n\n') + .trim() + return output || undefined +} + +export function createImportErrorResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + error: string, + syncedCount = 0 +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${flavorSessionIds.join(',') || '(none)'}`) + return { + success: false, + error, + cwd: workspace, + sessionIds: flavorSessionIds, + syncedCount + } +} + +export function createImportSuccessResponse( + flavor: TranscriptFlavor, + flavorSessionIds: string[], + results: ScriptLaunchResponse[] +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + const flavorLabel = flavor === 'codex' ? 'Codex' : 'Claude' + appendScriptLog( + workspace, + 'sync', + `SUCCESS: imported ${results.length} ${flavorLabel} session(s); sessionIds=${flavorSessionIds.join(',')}` + ) + return { + success: true, + message: `Imported ${results.length} ${flavorLabel} session(s) into Hapi`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: workspace, + output: combineSyncOutputs(results), + sessionIds: flavorSessionIds, + syncedCount: results.length + } +} + +export function importSingleSession(options: { + adapter: ImporterAdapter + sessionId: string + localSessionsById: Map + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): ScriptLaunchResponse { + const { adapter } = options + const flavorLabel = adapter.flavor === 'codex' ? 'Codex' : 'Claude' + const summary = options.localSessionsById.get(options.sessionId) + if (!summary) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Transcript not found for ${flavorLabel} session: ${options.sessionId}`), + output: `未找到对应的本地 transcript:${options.sessionId}` + } + } + + const transcript = adapter.parseTranscript(summary) + if (!transcript) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `Failed to parse ${flavorLabel} transcript: ${summary.file}`), + output: `解析 transcript 失败:${summary.file}` + } + } + + if (transcript.messages.length === 0) { + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], `No importable conversation content found in transcript: ${summary.file}`), + output: `transcript 中没有可导入的会话内容:${summary.file}` + } + } + + const importedComparableMessages = transcript.messages + .map((message) => normalizeComparableContent(message)) + .filter((value): value is string => value !== null) + + try { + const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) + const target = selectImportTargetSession( + options.store, + candidates, + adapter.sessionIdKey, + options.sessionId, + importedComparableMessages + ) + const engine = options.getSyncEngine?.() ?? null + const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null + const metadata = buildImportedSessionMetadata( + transcript, + adapter.flavor, + adapter.sessionIdKey, + asRecord(existingStored?.metadata), + resolveImportMachineId(transcript.cwd, options.namespace, engine) + ) + + let sessionId = existingStored?.id ?? null + let created = false + if (!sessionId) { + // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 + const createdSession = engine?.getOrCreateSession( + randomUUID(), + metadata, + {}, + options.namespace + ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + sessionId = createdSession.id + created = true + } else if (existingStored) { + const updatedMetadata = options.store.sessions.updateSessionMetadata( + existingStored.id, + metadata, + existingStored.metadataVersion, + options.namespace + ) + if (updatedMetadata.result !== 'success') { + throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) + } + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) + } + + if (!sessionId) { + throw new Error(`Failed to determine target Hapi session for ${flavorLabel} thread: ${options.sessionId}`) + } + + const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 + const messagesToAppend = transcript.messages.slice(comparablePrefixCount) + const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) + + // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 + const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() + if (engine) { + engine.recordSessionActivity(sessionId, latestMessageCreatedAt) + } else { + options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) + } + if (!created) { + emitImportedMessageEvents(engine, sessionId, appendedMessages) + } + + const output = [ + `${flavorLabel} thread: ${options.sessionId}`, + `Hapi session: ${sessionId}`, + `Action: ${created ? 'created' : 'updated'}`, + `Appended messages: ${appendedMessages.length}` + ].join('\n') + + appendScriptLog( + getDirectImportRouteContext().workspace, + 'sync', + `SUCCESS: ${adapter.sessionIdKey}=${options.sessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` + ) + + return { + success: true, + message: created ? `${flavorLabel} session imported into a new Hapi session` : `${flavorLabel} session appended to existing Hapi session`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: getDirectImportRouteContext().workspace, + output, + sessionIds: [options.sessionId], + syncedCount: 1 + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...createImportErrorResponse(adapter.flavor, [options.sessionId], message), + output: `${flavorLabel} thread: ${options.sessionId}\n${message}` + } + } +} + +export async function importSelectedSessions(options: { + adapter: ImporterAdapter + sessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const { adapter } = options + const sessionIds = options.sessionIds + if (sessionIds.length === 0) { + return createImportErrorResponse(adapter.flavor, sessionIds, NO_SYNC_SESSION_SELECTED_ERROR) + } + + const localSessionsById = new Map(adapter.listLocalSessions().map((session) => [session.id, session])) + const results: ScriptLaunchResponse[] = [] + for (const sessionId of sessionIds) { + const result = importSingleSession({ + adapter, + sessionId, + localSessionsById, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + results.push(result) + + if (!result.success) { + return { + ...result, + sessionIds, + syncedCount: Math.max(0, results.length - 1), + output: combineSyncOutputs(results) ?? result.output + } + } + } + + return createImportSuccessResponse(adapter.flavor, sessionIds, results) +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b0cf0592c5..a94f33da28 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -21,6 +21,7 @@ import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' import { createCodexDesktopRoutes } from './routes/codexDesktop' +import { createClaudeDesktopRoutes } from './routes/claudeDesktop' import { createPushRoutes } from './routes/push' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' @@ -245,6 +246,11 @@ function createWebApp(options: { store: options.store, getSyncEngine: options.getSyncEngine })) + // 中文注释:与 Codex 对称,扫描本地 ~/.claude/projects transcript 以导入 Hapi(复用同一套导入引擎)。 + app.route('/api', createClaudeDesktopRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createVoiceRoutes()) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ba33a89255..19a78ae845 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,6 +8,10 @@ import type { CodexDesktopSyncRequest, CodexDesktopStatusResponse, CodexCollaborationMode, + ClaudeLocalSessionsResponse, + ClaudeImportScriptResponse, + ClaudeImportSyncRequest, + ClaudeStatusResponse, FileSearchResponse, MachinesResponse, MessagesResponse, @@ -237,6 +241,22 @@ export class ApiClient { }) } + async syncClaudeSession(payload?: ClaudeImportSyncRequest): Promise { + // 中文注释:提交本地 transcript 对应的 Claude session ID 列表,后端按这些会话直接导入到 Hapi。 + return await this.request('/api/claude/sync-session', { + method: 'POST', + ...(payload ? { body: JSON.stringify(payload) } : {}) + }) + } + + async getClaudeSessions(): Promise { + return await this.request('/api/claude/sessions') + } + + async getClaudeStatus(): Promise { + return await this.request('/api/claude/status') + } + async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { await this.request('/api/push/subscribe', { method: 'DELETE', diff --git a/web/src/components/ClaudeSessionSyncDialog.test.tsx b/web/src/components/ClaudeSessionSyncDialog.test.tsx new file mode 100644 index 0000000000..0b9381f6a0 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.test.tsx @@ -0,0 +1,127 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { ClaudeSessionSyncDialog } from './ClaudeSessionSyncDialog' +import type { ClaudeLocalSessionSummary } from '@/types/api' + +function renderDialog( + sessions: ClaudeLocalSessionSummary[], + onConfirm = vi.fn(async () => {}), + currentClaudeSessionId: string | null = null +) { + const view = render( + + + + ) + return { ...view, onConfirm } +} + +describe('ClaudeSessionSyncDialog', () => { + afterEach(() => { + cleanup() + }) + + it('shows the working directory for local Claude sessions', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Session title', + lastUserMessage: 'Last prompt', + cwd: '/home/user/project', + file: '/home/user/.claude/projects/-home-user-project/session.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'claude_code', + cliVersion: '1.0.0' + } + ]) + + expect(screen.getByText('Working directory')).toBeInTheDocument() + expect(screen.getAllByText('/home/user/project')).toHaveLength(2) + }) + + it('filters sessions by working directory', () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ]) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + + expect(screen.queryByText('Project one')).not.toBeInTheDocument() + expect(screen.getByText('Project two')).toBeInTheDocument() + }) + + it('selects only filtered sessions when selecting all and confirms them', async () => { + const onConfirm = vi.fn(async () => {}) + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'claude-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.claude/projects/-home-user-project-two/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], onConfirm) + + fireEvent.change(screen.getByLabelText('Work directory'), { + target: { value: '/home/user/project-two' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Select all' })) + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import' })).toBeEnabled() + }) + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(onConfirm).toHaveBeenCalledWith(['claude-session-2']) + }) + + it('defaults selection to the linked current Claude session', async () => { + renderDialog([ + { + id: 'claude-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.claude/projects/-home-user-project-one/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + } + ], vi.fn(async () => {}), 'claude-session-1') + + await waitFor(() => { + expect(screen.getByText('1 sessions selected')).toBeInTheDocument() + expect(screen.getByText('Linked')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/ClaudeSessionSyncDialog.tsx b/web/src/components/ClaudeSessionSyncDialog.tsx new file mode 100644 index 0000000000..70ad55e833 --- /dev/null +++ b/web/src/components/ClaudeSessionSyncDialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react' +import type { ClaudeLocalSessionSummary } from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' +import { useTranslation } from '@/lib/use-translation' + +const CLAUDE_IMPORT_PICKER_LABELS = { + selectedCount: 'claudeSync.confirm.selectedCount', + selectAll: 'claudeSync.confirm.selectAll', + clearAll: 'claudeSync.confirm.clearAll', + cwdFilter: 'claudeSync.confirm.cwdFilter', + cwdFilterAll: 'claudeSync.confirm.cwdFilterAll', + cwd: 'claudeSync.confirm.cwd', + current: 'claudeSync.confirm.current', + loading: 'claudeSync.confirm.loading', + empty: 'claudeSync.confirm.empty', + emptyForWorkdir: 'claudeSync.confirm.emptyForWorkdir' +} as const + +export function ClaudeSessionSyncDialog(props: { + isOpen: boolean + onClose: () => void + sessions: ClaudeLocalSessionSummary[] + currentClaudeSessionId: string | null + onConfirm: (sessionIds: string[]) => Promise + isPending: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const { + isOpen, + sessions, + currentClaudeSessionId, + onConfirm, + isPending, + isLoading, + onClose + } = props + const [selectedSessionIds, setSelectedSessionIds] = useState([]) + + useEffect(() => { + if (!isOpen) { + setSelectedSessionIds([]) + } + }, [isOpen]) + + const handleConfirm = async () => { + if (selectedSessionIds.length === 0 || isPending || isLoading) return + + // 中文注释:确认按钮只提交用户勾选的 Claude session,落库由父组件统一处理并给出 toast 提示。 + await onConfirm(selectedSessionIds) + } + + return ( + !open && onClose()}> + + + {t('claudeSync.confirm.title')} + + {t('claudeSync.confirm.description')} + + + + + +
+ + +
+
+
+ ) +} diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index c7eefe6062..18332a1366 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { CodexLocalSessionSummary } from '@/types/api' import { Dialog, @@ -8,28 +8,21 @@ import { DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { SessionImportPicker } from '@/components/SessionImportPicker' import { useTranslation } from '@/lib/use-translation' -const ALL_WORKDIR_FILTER = '__all__' - -function formatCodexSessionTime(value: number): string | null { - if (!Number.isFinite(value)) return null - return new Date(value).toLocaleString() -} - -function getCodexSessionPreview(session: CodexLocalSessionSummary): string { - if (session.lastUserMessage?.trim()) { - return session.lastUserMessage.trim() - } - - const parts = [session.originator, session.cliVersion].filter(Boolean) - return parts.join(' · ') -} - -function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null { - const cwd = session.cwd?.trim() - return cwd ? cwd : null -} +const CODEX_IMPORT_PICKER_LABELS = { + selectedCount: 'codexSync.confirm.selectedCount', + selectAll: 'codexSync.confirm.selectAll', + clearAll: 'codexSync.confirm.clearAll', + cwdFilter: 'codexSync.confirm.cwdFilter', + cwdFilterAll: 'codexSync.confirm.cwdFilterAll', + cwd: 'codexSync.confirm.cwd', + current: 'codexSync.confirm.current', + loading: 'codexSync.confirm.loading', + empty: 'codexSync.confirm.empty', + emptyForWorkdir: 'codexSync.confirm.emptyForWorkdir' +} as const export function CodexSessionSyncDialog(props: { isOpen: boolean @@ -55,83 +48,13 @@ export function CodexSessionSyncDialog(props: { onClose } = props const [selectedSessionIds, setSelectedSessionIds] = useState([]) - const [hasInitializedSelection, setHasInitializedSelection] = useState(false) - const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) - const wasOpenRef = useRef(false) - - const sessionIdSet = useMemo( - () => new Set(sessions.map((session) => session.id)), - [sessions] - ) - const selectedSessionIdSet = useMemo( - () => new Set(selectedSessionIds), - [selectedSessionIds] - ) - const workdirOptions = useMemo(() => { - const directories = new Set() - for (const session of sessions) { - const cwd = getCodexSessionCwd(session) - if (cwd) directories.add(cwd) - } - return Array.from(directories).sort((a, b) => a.localeCompare(b)) - }, [sessions]) - const filteredSessions = useMemo(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return sessions - return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter) - }, [sessions, workdirFilter]) useEffect(() => { - if (isOpen && !wasOpenRef.current) { - wasOpenRef.current = true - setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) - return - } - - if (!isOpen && wasOpenRef.current) { - wasOpenRef.current = false + if (!isOpen) { setSelectedSessionIds([]) - setHasInitializedSelection(false) - setWorkdirFilter(ALL_WORKDIR_FILTER) } }, [isOpen]) - useEffect(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return - if (workdirOptions.includes(workdirFilter)) return - setWorkdirFilter(ALL_WORKDIR_FILTER) - }, [workdirFilter, workdirOptions]) - - useEffect(() => { - if (!isOpen || isLoading || hasInitializedSelection) return - - // 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。 - const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) - ? [currentCodexSessionId] - : [] - setSelectedSessionIds(defaultSelected) - setHasInitializedSelection(true) - }, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet]) - - const toggleSession = (sessionId: string) => { - if (isPending || isLoading) return - - // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 - setSelectedSessionIds((current) => current.includes(sessionId) - ? current.filter((id) => id !== sessionId) - : [...current, sessionId]) - } - - const selectAll = () => { - setSelectedSessionIds(filteredSessions.map((session) => session.id)) - } - - const clearAll = () => { - // 中文注释:全取消放在左侧,和底部“取消 / 导入”的左右语义保持一致。 - setSelectedSessionIds([]) - } - const handleConfirm = async () => { if (selectedSessionIds.length === 0 || isPending || isLoading) return @@ -163,121 +86,16 @@ export function CodexSessionSyncDialog(props: { -
-
-
- {t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })} -
-
- - -
-
- - {sessions.length > 0 ? ( - - ) : null} - -
- {isLoading ? ( -
- {t('codexSync.confirm.loading')} -
- ) : sessions.length === 0 ? ( -
- {t('codexSync.confirm.empty')} -
- ) : filteredSessions.length === 0 ? ( -
- {t('codexSync.confirm.emptyForWorkdir')} -
- ) : ( -
- {filteredSessions.map((session) => { - const checked = selectedSessionIdSet.has(session.id) - const time = formatCodexSessionTime(session.modifiedAt) - const preview = getCodexSessionPreview(session) - const cwd = getCodexSessionCwd(session) - return ( - - ) - })} -
- )} -
-
+
+ +
+ + + {sessions.length > 0 ? ( + + ) : null} + +
+ {isLoading ? ( +
+ {t(labels.loading)} +
+ ) : sessions.length === 0 ? ( +
+ {t(labels.empty)} +
+ ) : filteredSessions.length === 0 ? ( +
+ {t(labels.emptyForWorkdir)} +
+ ) : ( +
+ {filteredSessions.map((session) => { + const checked = selectedSessionIdSet.has(session.id) + const time = formatSessionTime(session.modifiedAt) + const preview = getSessionPreview(session) + const cwd = getSessionCwd(session) + return ( + + ) + })} +
+ )} +
+ + ) +} diff --git a/web/src/lib/claudeImportedSessions.ts b/web/src/lib/claudeImportedSessions.ts new file mode 100644 index 0000000000..57fb5b8013 --- /dev/null +++ b/web/src/lib/claudeImportedSessions.ts @@ -0,0 +1,94 @@ +const CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY = 'hapi.claudeImportedSessions' +const CLAUDE_IMPORTED_SESSIONS_EVENT = 'hapi:claude-imported-sessions-updated' + +type ClaudeImportedSessionsMap = Record + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined' +} + +function dispatchClaudeImportedSessionsChanged(): void { + if (!isBrowser()) return + window.dispatchEvent(new CustomEvent(CLAUDE_IMPORTED_SESSIONS_EVENT)) +} + +export function readClaudeImportedSessions(): ClaudeImportedSessionsMap { + if (!isBrowser()) return {} + try { + const raw = localStorage.getItem(CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + const result: ClaudeImportedSessionsMap = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof key === 'string' && typeof value === 'number' && Number.isFinite(value)) { + result[key] = value + } + } + return result + } catch { + return {} + } +} + +function writeClaudeImportedSessions(map: ClaudeImportedSessionsMap): void { + if (!isBrowser()) return + localStorage.setItem(CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY, JSON.stringify(map)) + dispatchClaudeImportedSessionsChanged() +} + +export function markClaudeSessionsImported(claudeSessionIds: string[], importedAt = Date.now()): void { + if (!isBrowser() || claudeSessionIds.length === 0) return + + // 中文注释:以 Claude session ID 为 key 记录导入时间,便于会话列表把时间文案切换成“从 Claude 导入”。 + const next = readClaudeImportedSessions() + for (const claudeSessionId of claudeSessionIds) { + const trimmed = claudeSessionId.trim() + if (trimmed) { + next[trimmed] = importedAt + } + } + writeClaudeImportedSessions(next) +} + +export function clearClaudeImportedSession(claudeSessionId: string | null | undefined): void { + if (!isBrowser() || !claudeSessionId) return + + const next = readClaudeImportedSessions() + if (!(claudeSessionId in next)) return + + // 中文注释:当用户已经在 Hapi 内继续这个会话后,移除导入标记,列表时间恢复为普通“xx 分钟前”。 + delete next[claudeSessionId] + writeClaudeImportedSessions(next) +} + +export function getClaudeImportedAt(claudeSessionId: string | null | undefined): number | null { + if (!claudeSessionId) return null + const importedAt = readClaudeImportedSessions()[claudeSessionId] + return typeof importedAt === 'number' && Number.isFinite(importedAt) ? importedAt : null +} + +export function subscribeClaudeImportedSessions(onChange: () => void): () => void { + if (!isBrowser()) { + return () => {} + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === CLAUDE_IMPORTED_SESSIONS_STORAGE_KEY) { + onChange() + } + } + const handleCustomEvent = () => { + onChange() + } + + window.addEventListener('storage', handleStorage) + window.addEventListener(CLAUDE_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CLAUDE_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d5f059c80c..5860887326 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -97,6 +97,28 @@ export default { 'codexSync.duplicates.merge.failed.title': 'Failed to merge duplicate sessions', 'codexSync.duplicates.merge.failed.body': 'Failed to merge duplicate sessions.', + // Claude session import + 'claudeSync.tooltip': 'Import sessions from Claude into Hapi', + 'claudeSync.confirm.title': 'Import Claude sessions', + 'claudeSync.confirm.description': 'Choose Claude sessions to import into Hapi', + 'claudeSync.confirm.selectAll': 'Select all', + 'claudeSync.confirm.clearAll': 'Clear all', + 'claudeSync.confirm.selectedCount': '{n} sessions selected', + 'claudeSync.confirm.empty': 'No local Claude sessions found', + 'claudeSync.confirm.current': 'Linked', + 'claudeSync.confirm.cwd': 'Working directory', + 'claudeSync.confirm.cwdFilter': 'Work directory', + 'claudeSync.confirm.cwdFilterAll': 'All work directories', + 'claudeSync.confirm.emptyForWorkdir': 'No Claude sessions in this work directory', + 'claudeSync.confirm.confirm': 'Import', + 'claudeSync.confirm.confirming': 'Importing…', + 'claudeSync.confirm.loading': 'Loading local Claude sessions…', + 'claudeSync.success.title': 'Import complete', + 'claudeSync.success.body': 'Imported {n} Claude session(s) into Hapi.', + 'claudeSync.failed.title': 'Failed to import Claude sessions', + 'claudeSync.failed.body': 'Failed to import Claude sessions.', + 'claudeSync.failed.bodyWithReason': 'Import failed: {reason}', + // Session list 'session.item.path': 'path', 'session.item.agent': 'agent', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 5e63692e94..8640c645cf 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -97,6 +97,28 @@ export default { 'codexSync.duplicates.merge.failed.title': '重复会话合并失败', 'codexSync.duplicates.merge.failed.body': '重复会话合并失败。', + // Claude 会话导入 + 'claudeSync.tooltip': '从 Claude 导入会话到 Hapi', + 'claudeSync.confirm.title': '导入 Claude 会话', + 'claudeSync.confirm.description': '选择需要导入到 Hapi 的 Claude 会话', + 'claudeSync.confirm.selectAll': '全选', + 'claudeSync.confirm.clearAll': '全取消', + 'claudeSync.confirm.selectedCount': '已选择 {n} 个会话', + 'claudeSync.confirm.empty': '未找到本地 Claude 会话', + 'claudeSync.confirm.current': '当前关联', + 'claudeSync.confirm.cwd': '工作目录', + 'claudeSync.confirm.cwdFilter': '工作目录', + 'claudeSync.confirm.cwdFilterAll': '全部工作目录', + 'claudeSync.confirm.emptyForWorkdir': '此工作目录下没有 Claude 会话', + 'claudeSync.confirm.confirm': '导入', + 'claudeSync.confirm.confirming': '导入中…', + 'claudeSync.confirm.loading': '正在读取本地 Claude 会话…', + 'claudeSync.success.title': '导入完成', + 'claudeSync.success.body': '已导入 {n} 个 Claude 会话到 Hapi。', + 'claudeSync.failed.title': '导入 Claude 会话失败', + 'claudeSync.failed.body': '导入 Claude 会话失败。', + 'claudeSync.failed.bodyWithReason': '导入失败:{reason}', + // Session list 'session.item.path': '路径', 'session.item.agent': '代理', diff --git a/web/src/router.tsx b/web/src/router.tsx index 84e030a531..dfb1e4fad8 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -16,6 +16,7 @@ import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' +import { ClaudeSessionSyncDialog } from '@/components/ClaudeSessionSyncDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' @@ -40,7 +41,8 @@ import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' -import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' +import { clearClaudeImportedSession, markClaudeSessionsImported } from '@/lib/claudeImportedSessions' +import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary, ClaudeLocalSessionSummary } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -106,6 +108,29 @@ function CodexImportIcon(props: { className?: string }) { ) } +function ClaudeImportIcon(props: { className?: string }) { + return ( + + {/* 中文注释:导入箭头叠加一条向下入库的指示,和 Codex 入口图标区分,弱化“聊天”含义。 */} + + + + + + ) +} + function FolderOpenIcon(props: { className?: string }) { return ( ([]) const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) + const [isSyncingClaudeSession, setIsSyncingClaudeSession] = useState(false) + const [claudeSessions, setClaudeSessions] = useState([]) + const [isLoadingClaudeSessions, setIsLoadingClaudeSessions] = useState(false) + const [isClaudeSyncConfirmOpen, setIsClaudeSyncConfirmOpen] = useState(false) const handleRefresh = useCallback(() => { void refetch() @@ -200,6 +229,9 @@ function SessionsPage() { const currentCodexSessionId = selectedSession?.metadata?.flavor === 'codex' ? (selectedSession.metadata.agentSessionId ?? null) : null + const currentClaudeSessionId = selectedSession?.metadata?.flavor === 'claude' + ? (selectedSession.metadata.agentSessionId ?? null) + : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => { @@ -453,6 +485,64 @@ function SessionsPage() { t ]) + const openClaudeImportDialog = useCallback(async () => { + if (isLoadingClaudeSessions) return + + setIsClaudeSyncConfirmOpen(true) + setIsLoadingClaudeSessions(true) + try { + const result = await api.getClaudeSessions() + setClaudeSessions(result.sessions) + } catch (error) { + setClaudeSessions([]) + addToast({ + title: t('claudeSync.failed.title'), + body: t('claudeSync.failed.bodyWithReason', { + reason: error instanceof Error ? error.message : t('dialog.error.default') + }), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingClaudeSessions(false) + } + }, [addToast, api, isLoadingClaudeSessions, t]) + + const handleImportClaudeSessions = useCallback(async (sessionIds: string[]) => { + if (isSyncingClaudeSession || isLoadingClaudeSessions) return + + setIsSyncingClaudeSession(true) + try { + // 中文注释:弹窗提交本地 Claude session ID;后端直接读取这些 transcript 并导入到 Hapi。 + const result = await api.syncClaudeSession({ sessionIds }) + if (!result.success) { + throw new Error(result.error || t('claudeSync.failed.body')) + } + + addToast({ + title: t('claudeSync.success.title'), + body: t('claudeSync.success.body', { n: result.syncedCount ?? sessionIds.length }), + sessionId: '', + url: '' + }) + // 中文注释:导入成功后先在浏览器侧记住这些 Claude session 的导入时间,供左侧会话列表显示特殊时间文案。 + markClaudeSessionsImported(sessionIds) + setIsClaudeSyncConfirmOpen(false) + await refetch() + } catch (syncError) { + addToast({ + title: t('claudeSync.failed.title'), + body: t('claudeSync.failed.bodyWithReason', { + reason: syncError instanceof Error ? syncError.message : t('dialog.error.default') + }), + sessionId: '', + url: '' + }) + } finally { + setIsSyncingClaudeSession(false) + } + }, [addToast, api, isLoadingClaudeSessions, isSyncingClaudeSession, refetch, t]) + return ( <>
@@ -477,6 +567,17 @@ function SessionsPage() { > + + ) : null} +
+ +
+ + +
+ + {flavor === 'codex' ? ( + + ) : ( + <> +
+ {t('cursorSync.confirm.acpStrictHint')} +
+ { + const raw = cursorSessionsById.get(session.id) + return Boolean(raw?.alreadyImportedHapiSessionId) + }} + renderSessionBadges={(session) => { + const raw = cursorSessionsById.get(session.id) + if (!raw) return null + const outcome = outcomesByUuid.get(session.id) + return ( + <> + + {raw.sourceFormat === 'acp' + ? t('cursorSync.confirm.sourceAcp') + : t('cursorSync.confirm.sourceLegacy')} + + {raw.alreadyImportedHapiSessionId ? ( + + {t('cursorSync.confirm.alreadyImported')} + + ) : null} + {outcome?.ok ? ( + + {t('cursorSync.outcome.ok')} + + ) : null} + + ) + }} + renderSessionFooter={(session) => { + const outcome = outcomesByUuid.get(session.id) + if (!outcome || outcome.ok) return null + return ( +
+
+ {t(cursorRefusalKey(outcome.reason))} +
+
+ {outcome.message} +
+
+ ) + }} + /> + + )} + +
+ + +
+ + + ) +} diff --git a/web/src/components/SessionImportPicker.tsx b/web/src/components/SessionImportPicker.tsx index 4fdb6f09ba..d4d63540f8 100644 --- a/web/src/components/SessionImportPicker.tsx +++ b/web/src/components/SessionImportPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { useTranslation } from '@/lib/use-translation' @@ -50,7 +50,7 @@ function getSessionCwd(session: ImportSessionSummary): string | null { /** * Shared transcript-import session picker (checkbox list + workdir filter + - * select/clear all). Codex and Claude import dialogs both render this so the + * select/clear all). Codex, Claude, and Cursor import dialogs render this so the * row/filter/selection logic lives in a single place (task R8). */ export function SessionImportPicker(props: { @@ -62,6 +62,10 @@ export function SessionImportPicker(props: { isPending: boolean isLoading: boolean labels: SessionImportPickerLabels + /** When set, matching rows are not selectable (e.g. already-imported cursor chats). */ + isSessionDisabled?: (session: ImportSessionSummary) => boolean + renderSessionBadges?: (session: ImportSessionSummary) => ReactNode + renderSessionFooter?: (session: ImportSessionSummary) => ReactNode }) { const { t } = useTranslation() const { @@ -72,7 +76,10 @@ export function SessionImportPicker(props: { onSelectionChange, isPending, isLoading, - labels + labels, + isSessionDisabled, + renderSessionBadges, + renderSessionFooter } = props const [hasInitializedSelection, setHasInitializedSelection] = useState(false) const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) @@ -138,6 +145,8 @@ export function SessionImportPicker(props: { const toggleSession = (sessionId: string) => { if (isPending || isLoading) return + const session = sessions.find((entry) => entry.id === sessionId) + if (session && isSessionDisabled?.(session)) return // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 onSelectionChange(selectedSessionIds.includes(sessionId) @@ -145,8 +154,13 @@ export function SessionImportPicker(props: { : [...selectedSessionIds, sessionId]) } + const selectableFilteredSessions = useMemo(() => { + if (!isSessionDisabled) return filteredSessions + return filteredSessions.filter((session) => !isSessionDisabled(session)) + }, [filteredSessions, isSessionDisabled]) + const selectAll = () => { - onSelectionChange(filteredSessions.map((session) => session.id)) + onSelectionChange(selectableFilteredSessions.map((session) => session.id)) } const clearAll = () => { @@ -175,7 +189,7 @@ export function SessionImportPicker(props: { variant="secondary" size="sm" onClick={selectAll} - disabled={isPending || isLoading || filteredSessions.length === 0} + disabled={isPending || isLoading || selectableFilteredSessions.length === 0} > {t(labels.selectAll)} @@ -219,20 +233,25 @@ export function SessionImportPicker(props: { ) : (
{filteredSessions.map((session) => { - const checked = selectedSessionIdSet.has(session.id) + const disabled = isSessionDisabled?.(session) ?? false + const checked = !disabled && selectedSessionIdSet.has(session.id) const time = formatSessionTime(session.modifiedAt) const preview = getSessionPreview(session) const cwd = getSessionCwd(session) return (
) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5860887326..b0819cc1da 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -97,6 +97,46 @@ export default { 'codexSync.duplicates.merge.failed.title': 'Failed to merge duplicate sessions', 'codexSync.duplicates.merge.failed.body': 'Failed to merge duplicate sessions.', + // Multi-agent import (codex + cursor tabs) + 'agentImport.tooltip': 'Import sessions from another agent into Hapi', + 'agentImport.confirm.title': 'Import sessions', + 'agentImport.flavor.label': 'Choose source agent', + 'agentImport.flavor.codex': 'Codex', + 'agentImport.flavor.cursor': 'Cursor', + + 'cursorSync.confirm.description': 'Choose Cursor chats to import into Hapi. Strict ACP-only: any chat that fails the ACP verify probe is unimportable.', + 'cursorSync.confirm.selectedCount': '{n} chats selected', + 'cursorSync.confirm.selectAll': 'Select all', + 'cursorSync.confirm.clearAll': 'Clear all', + 'cursorSync.confirm.confirm': 'Import', + 'cursorSync.confirm.confirming': 'Importing…', + 'cursorSync.confirm.loading': 'Loading local Cursor chats…', + 'cursorSync.confirm.empty': 'No local Cursor chats found', + 'cursorSync.confirm.emptyForWorkdir': 'No Cursor chats in this workspace', + 'cursorSync.confirm.cwd': 'Workspace', + 'cursorSync.confirm.cwdFilter': 'Workspace', + 'cursorSync.confirm.cwdFilterAll': 'All workspaces', + 'cursorSync.confirm.sourceAcp': 'ACP', + 'cursorSync.confirm.sourceLegacy': 'Legacy', + 'cursorSync.confirm.alreadyImported': 'Already imported', + 'cursorSync.confirm.acpStrictHint': 'Imports run agent acp verify-probe before any Hapi row is created. Chats that fail are reported per-row with a reason; no partial state is left behind.', + 'cursorSync.outcome.ok': 'Imported', + 'cursorSync.success.title': 'Cursor import complete', + 'cursorSync.success.body': 'Imported {n} Cursor chat(s) into Hapi.', + 'cursorSync.partial.title': 'Cursor import partially succeeded', + 'cursorSync.partial.body': 'Imported {ok} of {total} Cursor chats. See per-row details for the rest.', + 'cursorSync.failed.title': 'Failed to import Cursor chats', + 'cursorSync.failed.body': 'Failed to import Cursor chats.', + 'cursorSync.refusal.verify_load_failed': 'agent acp could not load this chat', + 'cursorSync.refusal.missing_on_disk_store': 'On-disk store.db is missing', + 'cursorSync.refusal.target_already_exists': 'Target ACP session directory already exists', + 'cursorSync.refusal.already_imported': 'Already imported as a Hapi session', + 'cursorSync.refusal.agent_binary_not_found': '`agent` (cursor-agent) binary is not on PATH', + 'cursorSync.refusal.verify_timeout': 'agent acp verify timed out', + 'cursorSync.refusal.corrupted_store': 'On-disk store.db looks corrupted', + 'cursorSync.refusal.ambiguous_legacy_store': 'Multiple workspace drawers contain this chat; specify a workspace', + 'cursorSync.refusal.internal_error': 'Internal error during import', + // Claude session import 'claudeSync.tooltip': 'Import sessions from Claude into Hapi', 'claudeSync.confirm.title': 'Import Claude sessions', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8640c645cf..3f074d5f11 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -97,6 +97,45 @@ export default { 'codexSync.duplicates.merge.failed.title': '重复会话合并失败', 'codexSync.duplicates.merge.failed.body': '重复会话合并失败。', + 'agentImport.tooltip': '从其他代理导入会话到 Hapi', + 'agentImport.confirm.title': '导入会话', + 'agentImport.flavor.label': '选择来源代理', + 'agentImport.flavor.codex': 'Codex', + 'agentImport.flavor.cursor': 'Cursor', + + 'cursorSync.confirm.description': '选择要导入 Hapi 的 Cursor 聊天。严格 ACP 模式:任何无法通过 ACP 校验的会话都不可导入。', + 'cursorSync.confirm.selectedCount': '已选 {n} 个聊天', + 'cursorSync.confirm.selectAll': '全选', + 'cursorSync.confirm.clearAll': '清空', + 'cursorSync.confirm.confirm': '导入', + 'cursorSync.confirm.confirming': '导入中…', + 'cursorSync.confirm.loading': '正在加载本地 Cursor 聊天…', + 'cursorSync.confirm.empty': '未找到本地 Cursor 聊天', + 'cursorSync.confirm.emptyForWorkdir': '该工作目录下没有 Cursor 聊天', + 'cursorSync.confirm.cwd': '工作区', + 'cursorSync.confirm.cwdFilter': '工作区', + 'cursorSync.confirm.cwdFilterAll': '所有工作区', + 'cursorSync.confirm.sourceAcp': 'ACP', + 'cursorSync.confirm.sourceLegacy': '旧版', + 'cursorSync.confirm.alreadyImported': '已导入', + 'cursorSync.confirm.acpStrictHint': '导入前会运行 agent acp 校验探针;未通过的会话会逐条返回失败原因,不会留下半完成状态。', + 'cursorSync.outcome.ok': '已导入', + 'cursorSync.success.title': 'Cursor 导入完成', + 'cursorSync.success.body': '已将 {n} 个 Cursor 聊天导入到 Hapi。', + 'cursorSync.partial.title': 'Cursor 导入部分成功', + 'cursorSync.partial.body': '共 {total} 个聊天,成功 {ok} 个;其余请见每行原因。', + 'cursorSync.failed.title': '导入 Cursor 聊天失败', + 'cursorSync.failed.body': '导入 Cursor 聊天失败。', + 'cursorSync.refusal.verify_load_failed': 'agent acp 无法加载该聊天', + 'cursorSync.refusal.missing_on_disk_store': '本地未找到 store.db', + 'cursorSync.refusal.target_already_exists': 'ACP 目标目录已存在', + 'cursorSync.refusal.already_imported': '该会话已在 Hapi 中存在', + 'cursorSync.refusal.agent_binary_not_found': '在 PATH 中未找到 cursor-agent (`agent`) 可执行文件', + 'cursorSync.refusal.verify_timeout': 'agent acp 校验超时', + 'cursorSync.refusal.corrupted_store': '本地 store.db 似乎已损坏', + 'cursorSync.refusal.ambiguous_legacy_store': '同一会话存在于多个工作区目录;请指定工作区', + 'cursorSync.refusal.internal_error': '导入过程中发生内部错误', + // Claude 会话导入 'claudeSync.tooltip': '从 Claude 导入会话到 Hapi', 'claudeSync.confirm.title': '导入 Claude 会话', diff --git a/web/src/router.tsx b/web/src/router.tsx index dfb1e4fad8..00dd41f730 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -15,7 +15,7 @@ import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' -import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' +import { AgentSessionImportDialog } from '@/components/AgentSessionImportDialog' import { ClaudeSessionSyncDialog } from '@/components/ClaudeSessionSyncDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' @@ -42,7 +42,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' import { clearClaudeImportedSession, markClaudeSessionsImported } from '@/lib/claudeImportedSessions' -import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary, ClaudeLocalSessionSummary } from '@/types/api' +import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary, ClaudeLocalSessionSummary, AgentImportFlavor, CursorImportableSessionSummary, CursorImportRowOutcome } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -199,6 +199,11 @@ function SessionsPage() { const [claudeSessions, setClaudeSessions] = useState([]) const [isLoadingClaudeSessions, setIsLoadingClaudeSessions] = useState(false) const [isClaudeSyncConfirmOpen, setIsClaudeSyncConfirmOpen] = useState(false) + const [importFlavor, setImportFlavor] = useState('codex') + const [cursorSessions, setCursorSessions] = useState([]) + const [isLoadingCursorSessions, setIsLoadingCursorSessions] = useState(false) + const [isImportingCursorSessions, setIsImportingCursorSessions] = useState(false) + const [cursorLastOutcomes, setCursorLastOutcomes] = useState(null) const handleRefresh = useCallback(() => { void refetch() @@ -381,10 +386,33 @@ function SessionsPage() { t ]) + const loadCursorImportableSessions = useCallback(async () => { + setIsLoadingCursorSessions(true) + try { + const result = await api.getCursorImportableSessions() + if (!result.success) { + throw new Error(result.error || t('cursorSync.failed.body')) + } + setCursorSessions(result.sessions) + } catch (error) { + setCursorSessions([]) + addToast({ + title: t('cursorSync.failed.title'), + body: error instanceof Error ? error.message : t('cursorSync.failed.body'), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingCursorSessions(false) + } + }, [addToast, api, t]) + const openCodexImportDialog = useCallback(async () => { if (isLoadingCodexSessions) return setIsSyncConfirmOpen(true) + setCursorLastOutcomes(null) + void loadCursorImportableSessions() setIsLoadingCodexSessions(true) try { const result = await api.getCodexSessions() @@ -404,7 +432,56 @@ function SessionsPage() { } finally { setIsLoadingCodexSessions(false) } - }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, normalizeCodexScriptError, t]) + }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, loadCursorImportableSessions, normalizeCodexScriptError, t]) + + const handleImportCursorSessions = useCallback(async (uuids: string[]) => { + if (isImportingCursorSessions || isLoadingCursorSessions) return + setIsImportingCursorSessions(true) + try { + const result = await api.importCursorSessions({ uuids }) + if (!result.success) { + throw new Error(result.error || t('cursorSync.failed.body')) + } + setCursorLastOutcomes(result.results) + const okCount = result.importedCount + const total = result.results.length + if (okCount === total) { + addToast({ + title: t('cursorSync.success.title'), + body: t('cursorSync.success.body', { n: okCount }), + sessionId: '', + url: '' + }) + setIsSyncConfirmOpen(false) + } else { + addToast({ + title: t('cursorSync.partial.title'), + body: t('cursorSync.partial.body', { ok: okCount, total }), + sessionId: '', + url: '' + }) + } + await refetch() + void loadCursorImportableSessions() + } catch (error) { + addToast({ + title: t('cursorSync.failed.title'), + body: error instanceof Error ? error.message : t('cursorSync.failed.body'), + sessionId: '', + url: '' + }) + } finally { + setIsImportingCursorSessions(false) + } + }, [ + addToast, + api, + isImportingCursorSessions, + isLoadingCursorSessions, + loadCursorImportableSessions, + refetch, + t + ]) const handleImportCodexSessions = useCallback(async (sessionIds: string[]) => { if (isSyncingCodexSession || isLoadingCodexSessions) return @@ -559,13 +636,13 @@ function SessionsPage() { + {flavor === 'codex' ? ( @@ -230,7 +301,7 @@ export function AgentSessionImportDialog(props: { isLoading={isLoadingCodex} labels={CODEX_IMPORT_PICKER_LABELS} /> - ) : ( + ) : flavor === 'cursor' ? ( <>
{t('cursorSync.confirm.acpStrictHint')} @@ -288,6 +359,17 @@ export function AgentSessionImportDialog(props: { }} /> + ) : ( + )}
@@ -306,16 +388,24 @@ export function AgentSessionImportDialog(props: { disabled={ isPending || isLoading - || (flavor === 'codex' ? selectedCodexIds.length === 0 : selectedCursorIds.length === 0) + || (flavor === 'codex' + ? selectedCodexIds.length === 0 + : flavor === 'cursor' + ? selectedCursorIds.length === 0 + : selectedClaudeIds.length === 0) } > {isPending ? (flavor === 'codex' ? t('codexSync.confirm.confirming') - : t('cursorSync.confirm.confirming')) + : flavor === 'cursor' + ? t('cursorSync.confirm.confirming') + : t('claudeSync.confirm.confirming')) : (flavor === 'codex' ? t('codexSync.confirm.confirm') - : t('cursorSync.confirm.confirm'))} + : flavor === 'cursor' + ? t('cursorSync.confirm.confirm') + : t('claudeSync.confirm.confirm'))}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index b0819cc1da..d86ba08dd2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -103,6 +103,7 @@ export default { 'agentImport.flavor.label': 'Choose source agent', 'agentImport.flavor.codex': 'Codex', 'agentImport.flavor.cursor': 'Cursor', + 'agentImport.flavor.claude': 'Claude', 'cursorSync.confirm.description': 'Choose Cursor chats to import into Hapi. Strict ACP-only: any chat that fails the ACP verify probe is unimportable.', 'cursorSync.confirm.selectedCount': '{n} chats selected', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3f074d5f11..c935005fa3 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -102,6 +102,7 @@ export default { 'agentImport.flavor.label': '选择来源代理', 'agentImport.flavor.codex': 'Codex', 'agentImport.flavor.cursor': 'Cursor', + 'agentImport.flavor.claude': 'Claude', 'cursorSync.confirm.description': '选择要导入 Hapi 的 Cursor 聊天。严格 ACP 模式:任何无法通过 ACP 校验的会话都不可导入。', 'cursorSync.confirm.selectedCount': '已选 {n} 个聊天', diff --git a/web/src/router.tsx b/web/src/router.tsx index 00dd41f730..a67913ca17 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -16,7 +16,6 @@ import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' import { AgentSessionImportDialog } from '@/components/AgentSessionImportDialog' -import { ClaudeSessionSyncDialog } from '@/components/ClaudeSessionSyncDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' @@ -108,29 +107,6 @@ function CodexImportIcon(props: { className?: string }) { ) } -function ClaudeImportIcon(props: { className?: string }) { - return ( - - {/* 中文注释:导入箭头叠加一条向下入库的指示,和 Codex 入口图标区分,弱化“聊天”含义。 */} - - - - - - ) -} - function FolderOpenIcon(props: { className?: string }) { return ( ([]) const [isLoadingClaudeSessions, setIsLoadingClaudeSessions] = useState(false) - const [isClaudeSyncConfirmOpen, setIsClaudeSyncConfirmOpen] = useState(false) const [importFlavor, setImportFlavor] = useState('codex') const [cursorSessions, setCursorSessions] = useState([]) const [isLoadingCursorSessions, setIsLoadingCursorSessions] = useState(false) @@ -407,12 +382,33 @@ function SessionsPage() { } }, [addToast, api, t]) + const loadClaudeImportableSessions = useCallback(async () => { + setIsLoadingClaudeSessions(true) + try { + const result = await api.getClaudeSessions() + setClaudeSessions(result.sessions) + } catch (error) { + setClaudeSessions([]) + addToast({ + title: t('claudeSync.failed.title'), + body: t('claudeSync.failed.bodyWithReason', { + reason: error instanceof Error ? error.message : t('dialog.error.default') + }), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingClaudeSessions(false) + } + }, [addToast, api, t]) + const openCodexImportDialog = useCallback(async () => { if (isLoadingCodexSessions) return setIsSyncConfirmOpen(true) setCursorLastOutcomes(null) void loadCursorImportableSessions() + void loadClaudeImportableSessions() setIsLoadingCodexSessions(true) try { const result = await api.getCodexSessions() @@ -432,7 +428,7 @@ function SessionsPage() { } finally { setIsLoadingCodexSessions(false) } - }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, loadCursorImportableSessions, normalizeCodexScriptError, t]) + }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, loadClaudeImportableSessions, loadCursorImportableSessions, normalizeCodexScriptError, t]) const handleImportCursorSessions = useCallback(async (uuids: string[]) => { if (isImportingCursorSessions || isLoadingCursorSessions) return @@ -562,29 +558,6 @@ function SessionsPage() { t ]) - const openClaudeImportDialog = useCallback(async () => { - if (isLoadingClaudeSessions) return - - setIsClaudeSyncConfirmOpen(true) - setIsLoadingClaudeSessions(true) - try { - const result = await api.getClaudeSessions() - setClaudeSessions(result.sessions) - } catch (error) { - setClaudeSessions([]) - addToast({ - title: t('claudeSync.failed.title'), - body: t('claudeSync.failed.bodyWithReason', { - reason: error instanceof Error ? error.message : t('dialog.error.default') - }), - sessionId: '', - url: '' - }) - } finally { - setIsLoadingClaudeSessions(false) - } - }, [addToast, api, isLoadingClaudeSessions, t]) - const handleImportClaudeSessions = useCallback(async (sessionIds: string[]) => { if (isSyncingClaudeSession || isLoadingClaudeSessions) return @@ -604,7 +577,7 @@ function SessionsPage() { }) // 中文注释:导入成功后先在浏览器侧记住这些 Claude session 的导入时间,供左侧会话列表显示特殊时间文案。 markClaudeSessionsImported(sessionIds) - setIsClaudeSyncConfirmOpen(false) + setIsSyncConfirmOpen(false) await refetch() } catch (syncError) { addToast({ @@ -636,24 +609,13 @@ function SessionsPage() { -
- {/* Multi-agent session import: Codex + Cursor tabs (Claude stays on its own toolbar icon from #942). */} + {/* Multi-agent session import: Codex + Cursor + Claude tabs. */} setIsSyncConfirmOpen(false)} @@ -739,16 +701,11 @@ function SessionsPage() { isPendingCursor={isImportingCursorSessions} cursorLastOutcomes={cursorLastOutcomes} onConfirmCursor={handleImportCursorSessions} - /> - {/* 中文注释:展示本地 Claude transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Claude session。 */} - setIsClaudeSyncConfirmOpen(false)} - sessions={claudeSessions} + claudeSessions={claudeSessions} currentClaudeSessionId={currentClaudeSessionId} - onConfirm={handleImportClaudeSessions} - isPending={isSyncingClaudeSession} - isLoading={isLoadingClaudeSessions} + isLoadingClaude={isLoadingClaudeSessions} + isPendingClaude={isSyncingClaudeSession} + onConfirmClaude={handleImportClaudeSessions} /> 0} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index ceaaf710c9..6cbe087db7 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -251,7 +251,7 @@ export type ClaudeStatusResponse = { claudeProjectsAvailable: boolean } -export type AgentImportFlavor = 'codex' | 'cursor' +export type AgentImportFlavor = 'codex' | 'cursor' | 'claude' export type CursorImportSourceFormat = 'legacy' | 'acp' From 68653778ff094bf3d3fe667bad7e372d0550f28f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:51:41 +0100 Subject: [PATCH 9/9] chore(scripts): add Playwright handoff capture for import picker Reproducible PNG for operator demo (#732); MCP/display_image stash content was already shipped via #944 upstream. Co-authored-by: Cursor --- scripts/dev/agent-import-picker-handoff.mjs | 102 ++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100755 scripts/dev/agent-import-picker-handoff.mjs diff --git a/scripts/dev/agent-import-picker-handoff.mjs b/scripts/dev/agent-import-picker-handoff.mjs new file mode 100755 index 0000000000..5a97f4c58e --- /dev/null +++ b/scripts/dev/agent-import-picker-handoff.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Playwright handoff capture for the multi-agent session import picker (#732). + * + * Opens the sessions index, clicks the import affordance, switches to the + * Cursor flavor tab (Codex | Cursor | Claude dialog), and writes a full-page PNG. + * + * Usage (from repo root, hub serving this branch's embedded web on :3076): + * CLI_API_TOKEN=demo-token node scripts/dev/agent-import-picker-handoff.mjs \ + * --base http://127.0.0.1:3076 \ + * --token demo-token \ + * --out localdocs/playwright-runs/agent-import-picker-handoff.png + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +function parseArgs(argv) { + const opts = { + base: 'http://127.0.0.1:3076', + token: process.env.CLI_API_TOKEN ?? '', + out: 'localdocs/playwright-runs/agent-import-picker-handoff.png', + timeout: 30_000 + } + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (arg === '--base') opts.base = argv[++i] + else if (arg === '--token') opts.token = argv[++i] + else if (arg === '--out') opts.out = argv[++i] + else if (arg === '--timeout') opts.timeout = Number(argv[++i]) + } + if (!opts.token) { + throw new Error('missing --token or CLI_API_TOKEN') + } + return opts +} + +function launchOptions() { + const chromePath = process.env.PLAYWRIGHT_CHROME_PATH?.trim() + if (chromePath) return { headless: true, executablePath: chromePath } + if (process.platform === 'linux') return { headless: true, channel: 'chrome' } + return { headless: true } +} + +const opts = parseArgs(process.argv.slice(2)) +const outPath = resolve(opts.out) +mkdirSync(dirname(outPath), { recursive: true }) + +const browser = await chromium.launch(launchOptions()) +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }) +const consoleMessages = [] +const failedRequests = [] +page.on('console', (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)) +page.on('requestfailed', (req) => failedRequests.push(`${req.method()} ${req.url()} ${req.failure()?.errorText ?? ''}`)) + +try { + const url = `${opts.base.replace(/\/$/, '')}/sessions?token=${encodeURIComponent(opts.token)}` + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: opts.timeout }) + await page.waitForTimeout(1500) + + // Import affordance (toolbar button). + const importButton = page.getByRole('button', { name: /import sessions from another agent/i }) + await importButton.waitFor({ timeout: opts.timeout }) + await importButton.click() + + // Dialog + flavor tabs. + await page.getByRole('dialog').waitFor({ timeout: opts.timeout }) + await page.getByText('Import sessions').waitFor({ timeout: opts.timeout }) + await page.getByRole('tab', { name: 'Cursor' }).click() + + // Wait for cursor list or loading/empty state inside the dialog. + await page.getByText(/Loading local Cursor chats|No local Cursor chats|chats selected|Strict ACP-only/i) + .first() + .waitFor({ timeout: opts.timeout }) + + await page.screenshot({ path: outPath, fullPage: true }) + + const bodyText = await page.locator('body').innerText() + const result = { + ok: true, + screenshot: outPath, + url: page.url().replace(/([?&]token=)[^&]+/g, '$1'), + hasCursorTab: bodyText.includes('Cursor'), + hasAcpHint: /Strict ACP-only|acp verify-probe/i.test(bodyText), + consoleMessages, + failedRequests + } + console.log(JSON.stringify(result, null, 2)) + if (failedRequests.length > 0) process.exitCode = 2 +} catch (error) { + await page.screenshot({ path: outPath, fullPage: true }).catch(() => {}) + console.error(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + screenshot: outPath, + consoleMessages, + failedRequests + }, null, 2)) + process.exitCode = 1 +} finally { + await browser.close() +}