diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index 74d1b27..ec599a9 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -5,6 +5,7 @@ import { api, assertSafeWebAuthTransport, getStoredToken, + isElectronCloudHttpAuthAllowed, isInsecureRemoteHttpLocation, setStoredToken, } from "@/lib/api.ts"; @@ -26,7 +27,7 @@ export function AuthGate({ children }: { children: ReactNode }) { const refresh = useCallback(async () => { setError(null); setState((current) => ({ status: "loading", providerWarnings: current.providerWarnings })); - if (getStoredToken() && isInsecureRemoteHttpLocation()) { + if (getStoredToken() && isInsecureRemoteHttpLocation() && !isElectronCloudHttpAuthAllowed()) { setError(t("auth.insecure_http")); setState({ status: "token", providerWarnings: [] }); return; @@ -63,7 +64,7 @@ export function AuthGate({ children }: { children: ReactNode }) { setSubmitting(true); setError(null); try { - if (isInsecureRemoteHttpLocation()) { + if (isInsecureRemoteHttpLocation() && !isElectronCloudHttpAuthAllowed()) { setError(t("auth.insecure_http")); return; } diff --git a/client/src/hooks/use-import-stream.ts b/client/src/hooks/use-import-stream.ts index 2311271..d8d9e5f 100644 --- a/client/src/hooks/use-import-stream.ts +++ b/client/src/hooks/use-import-stream.ts @@ -46,6 +46,7 @@ export function useImportStream() { } fetch(`${API_BASE}/api/import/scan/stream`, { + method: 'POST', headers, }) .then(async (res) => { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b51ffb3..c2884b3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -25,8 +25,15 @@ export function isInsecureRemoteHttpLocation(location = window.location): boolea return location.protocol === "http:" && !LOCAL_HOSTS.has(location.hostname); } +export function isElectronCloudHttpAuthAllowed(location = window.location): boolean { + const marker = window.chatcrystalElectronCloud; + if (!marker?.allowInsecureHttpAuth) return false; + return marker.origin === location.origin; +} + export function assertSafeWebAuthTransport(): void { if (!isInsecureRemoteHttpLocation()) return; + if (isElectronCloudHttpAuthAllowed()) return; throw new Error( "Refusing to send ChatCrystal access tokens over public HTTP. Use HTTPS or a local tunnel.", ); diff --git a/client/src/types/electron.d.ts b/client/src/types/electron.d.ts new file mode 100644 index 0000000..849a88e --- /dev/null +++ b/client/src/types/electron.d.ts @@ -0,0 +1,10 @@ +export {}; + +declare global { + interface Window { + chatcrystalElectronCloud?: { + allowInsecureHttpAuth: boolean; + origin: string; + }; + } +} diff --git a/docs/superpowers/plans/2026-05-23-electron-onboarding-phase2.md b/docs/superpowers/plans/2026-05-23-electron-onboarding-phase2.md new file mode 100644 index 0000000..b918c14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-electron-onboarding-phase2.md @@ -0,0 +1,1566 @@ +# Electron Onboarding Phase 2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship Windows-first Electron onboarding with local/cloud mode choice, cloud connection, explicit local-history import/upload, cloud Web UI auto-login, model testing, and MCP copy-ready snippets. + +**Architecture:** Keep the Fastify Core as the source of API truth and reuse existing import/parser/remote-ingest services. Electron becomes a mode-aware shell with separate trust boundaries: onboarding/local renderer gets restricted IPC, cloud Web UI gets no high-privilege import/config IPC, and cloud mode does not start the embedded local Core. Phase 2 deliberately skips preview/count UI; local histories are scanned and parsed only after the user starts import/upload. + +**Tech Stack:** TypeScript, Electron, Fastify v5, React/Vite Web UI, sql.js, Commander/MCP stdio, node:test, Electron `contextBridge`, Electron `session`, `BrowserWindow`. + +--- + +## Scope Check + +This plan implements `docs/superpowers/specs/2026-05-23-electron-onboarding-phase2-design.md`. + +Included: + +- Electron first-run onboarding with local/cloud mode choice. +- Cloud URL/token verification and plaintext JSON storage in Electron `userData`. +- Electron cloud Web UI loading with exact-origin navigation lock and `chatcrystal.apiToken` localStorage injection. +- Electron-specific public HTTP allowance for cloud Web UI only; normal browser behavior remains unchanged. +- Explicit import/upload action after mode connection; no preview/count layer. +- Server/Core contracts for watcher disablement, structured import/upload result IDs, and summarize-by-ids. +- MCP Helper snippets for local/cloud, including local active Core URL and non-local HTTP allow env. + +Excluded: + +- Multi-user accounts, sync/conflict resolution, and multiple cloud profiles. +- Auto-writing MCP config files. +- Auto-installing the npm CLI. +- Electron-bundled MCP executable. +- macOS/Linux installers. +- A source preview/count layer. + +## Delivery Gates + +- **Gate 2A: Core contracts** adds watcher control, structured import/upload result IDs, and summarize-by-ids APIs. +- **Gate 2B: Web/Electron trust boundary** adds Electron-safe Web HTTP auth allowance, Electron state, preloads, mode-aware shell, and exact-origin cloud loading. +- **Gate 2C: Onboarding UI and MCP helper** adds the first-run flow, explicit import/upload action, model test and summarize prompts, error recovery, and MCP snippets. + +Each gate should compile, pass focused tests, and commit before moving to the next gate. + +## File Structure + +| File | Responsibility | +|------|----------------| +| `shared/types/index.ts` | Shared import/upload result, summarize-by-ids, Electron onboarding state, and MCP snippet types. | +| `server/src/runtime/watcherPolicy.ts` | Pure helper deciding whether a server instance starts the watcher. | +| `server/src/runtime/watcherPolicy.test.ts` | Tests that Electron onboarding disables watcher and standalone local mode keeps it. | +| `server/src/index.ts` | Adds `createServer({ startWatcher })` and uses watcher policy. | +| `server/src/services/import.ts` | Returns structured `importedIds`, `replacedIds`, `skippedIds`, `errorIds`, `summarizationCandidateIds`. | +| `server/src/services/import.test.ts` | Verifies structured IDs and skipped IDs are excluded from summary candidates. | +| `server/src/services/ingest.ts` | Adds structured ID arrays to remote ingest response. | +| `server/src/services/ingest.test.ts` | Verifies remote ingest candidate IDs for imported/replaced and excludes skipped. | +| `server/src/routes/import.ts` | Surfaces structured import/upload result data through existing import routes. | +| `server/src/routes/notes.ts` | Adds summarize-by-ids and summarize-status-by-ids endpoints. | +| `server/src/routes/notes-summarize-batch.test.ts` | Tests summarize-by-ids rejects unknown IDs and does not queue skipped/backlog IDs. | +| `client/src/lib/api.ts` | Adds Electron cloud HTTP auth allowance while preserving normal browser guard. | +| `client/src/components/AuthGate.tsx` | Uses the updated guard and keeps normal browser public HTTP blocked. | +| `electron/state.ts` | Reads/writes versioned Electron onboarding state under `app.getPath("userData")`. | +| `electron/state.test.ts` | Tests plaintext token persistence, token redaction helper, and corrupted state fallback. | +| `electron/preload.ts` | Keeps existing minimal local preload. | +| `electron/onboarding-preload.ts` | Exposes only onboarding IPC methods to the Electron-owned onboarding renderer. | +| `electron/cloud-preload.ts` | Exposes only a low-privilege Electron cloud marker for Web HTTP guard allowance. | +| `electron/ipc.ts` | Registers guarded onboarding IPC handlers and validates sender origin/state. | +| `electron/mcp-snippets.ts` | Builds copy-ready local/cloud MCP snippets. | +| `electron/mcp-snippets.test.ts` | Verifies local URL, cloud token, and non-local HTTP allow env. | +| `electron/onboarding-page.ts` | Self-contained onboarding HTML/CSS/JS string loaded by Electron shell. | +| `electron/main.ts` | Mode-aware startup, local Core lifecycle, cloud Web UI loading, exact-origin lock, token injection. | +| `electron/tray.ts` | Mode-aware tray menu targets. | +| `electron/tsconfig.json` | Includes new Electron TS files. | +| `client/src/types/electron.d.ts` | Browser-side global types for the low-privilege Electron cloud marker. | + +--- + +## Gate 2A: Core Contracts + +### Task 1: Add Watcher Startup Policy + +**Files:** +- Create: `server/src/runtime/watcherPolicy.ts` +- Create: `server/src/runtime/watcherPolicy.test.ts` +- Modify: `server/src/index.ts` +- Test: `server/src/runtime/watcherPolicy.test.ts` + +- [ ] **Step 1: Write the failing watcher policy tests** + +Create `server/src/runtime/watcherPolicy.test.ts`: + +```ts +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldStartWatcher } from './watcherPolicy.js'; + +test('standalone local server starts watcher by default', () => { + assert.equal(shouldStartWatcher({ cloudMode: false }), true); +}); + +test('cloud server never starts watcher by default', () => { + assert.equal(shouldStartWatcher({ cloudMode: true }), false); +}); + +test('electron onboarding can explicitly disable watcher in local mode', () => { + assert.equal(shouldStartWatcher({ cloudMode: false, startWatcher: false }), false); +}); + +test('explicit true still cannot start watcher in cloud mode', () => { + assert.equal(shouldStartWatcher({ cloudMode: true, startWatcher: true }), false); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +npm run test -w server -- src/runtime/watcherPolicy.test.ts +``` + +Expected: FAIL because `server/src/runtime/watcherPolicy.ts` does not exist. + +- [ ] **Step 3: Add the watcher policy helper** + +Create `server/src/runtime/watcherPolicy.ts`: + +```ts +export type WatcherPolicyInput = { + cloudMode: boolean; + startWatcher?: boolean; +}; + +export function shouldStartWatcher(input: WatcherPolicyInput): boolean { + if (input.cloudMode) return false; + return input.startWatcher ?? true; +} +``` + +- [ ] **Step 4: Wire `createServer({ startWatcher })`** + +In `server/src/index.ts`, import the helper: + +```ts +import { shouldStartWatcher } from './runtime/watcherPolicy.js'; +``` + +Change the `createServer` options type: + +```ts +export async function createServer(options?: { + port?: number; + host?: string; + startWatcher?: boolean; +}): Promise { +``` + +Replace watcher startup with: + +```ts + const cloudMode = isCloudMode(); + const watcher = shouldStartWatcher({ + cloudMode, + startWatcher: options?.startWatcher, + }) + ? startWatcher() + : null; +``` + +- [ ] **Step 5: Run watcher policy tests** + +Run: + +```bash +npm run test -w server -- src/runtime/watcherPolicy.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/src/runtime/watcherPolicy.ts server/src/runtime/watcherPolicy.test.ts server/src/index.ts +git commit -m "feat: add server watcher startup policy" +``` + +### Task 2: Return Structured Local Import IDs + +**Files:** +- Modify: `shared/types/index.ts` +- Modify: `server/src/services/import.ts` +- Modify: `server/src/services/import.test.ts` +- Modify: `server/src/routes/import.ts` + +- [ ] **Step 1: Add shared import batch types** + +Append to `shared/types/index.ts` near the remote import types: + +```ts +export interface ImportBatchResult { + total: number; + imported: number; + replaced: number; + skipped: number; + errors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; +} +``` + +- [ ] **Step 2: Write failing import result tests** + +Add to `server/src/services/import.test.ts`: + +```ts +test('importAll returns structured ids and excludes skipped from summary candidates', async () => { + const { db, importAll, registerAdapter, appConfig } = await loadRuntime(); + resetDatabase(db); + + appConfig.enabledSources = ['codex']; + const skippedParsed = parsedConversation('codex-same', 'codex', ['hello', 'world']); + const skippedHash = computeConversationContentHash(skippedParsed); + db.run( + `INSERT INTO conversations ( + id, slug, source, source_conversation_id, content_hash, parser_version, + project_dir, project_name, cwd, git_branch, + message_count, first_message_at, last_message_at, + file_path, file_size, file_mtime, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'imported')`, + [ + 'codex-same', + 'codex-same-slug', + 'codex', + 'codex-same', + skippedHash, + 'codex@test', + 'C:/repo', + 'repo', + 'C:/repo', + 'main', + 2, + skippedParsed.firstMessageAt, + skippedParsed.lastMessageAt, + 'C:/fixtures/codex-same.jsonl', + 100, + '2026-05-23T00:00:00.000Z', + ], + ); + + registerAdapter(testAdapter( + 'codex', + [ + conversationMeta('codex-new', 'codex', 100, '2026-05-23T00:00:00.000Z'), + conversationMeta('codex-same', 'codex', 100, '2026-05-23T00:00:00.000Z'), + ], + new Map([ + ['codex-new', parsedConversation('codex-new', 'codex', ['new user', 'new assistant'])], + ['codex-same', skippedParsed], + ]), + )); + + const result = await importAll(); + + assert.deepEqual(result.importedIds, ['codex-new']); + assert.deepEqual(result.replacedIds, []); + assert.deepEqual(result.skippedIds, ['codex-same']); + assert.deepEqual(result.summarizationCandidateIds, ['codex-new']); +}); +``` + +- [ ] **Step 3: Run the failing import test** + +Run: + +```bash +npm run test -w server -- src/services/import.test.ts +``` + +Expected: FAIL because `importAll()` does not return structured ID arrays. + +- [ ] **Step 4: Update `ImportProgress` and `importAll()`** + +In `server/src/services/import.ts`, change the import: + +```ts +import type { ConversationMeta, ImportBatchResult, ParsedConversation } from "@chatcrystal/shared"; +``` + +Change `ImportProgress`: + +```ts +export interface ImportProgress extends ImportBatchResult { + current: number; + currentFile: string; +} +``` + +Initialize progress with ID arrays: + +```ts + const progress: ImportProgress = { + total: allMetas.length, + current: 0, + currentFile: "", + imported: 0, + replaced: 0, + skipped: 0, + errors: 0, + importedIds: [], + replacedIds: [], + skippedIds: [], + errorIds: [], + summarizationCandidateIds: [], + }; +``` + +When size/mtime skip fires, add: + +```ts +progress.skippedIds.push(meta.id); +``` + +When `existingRow && existingContentHash === contentHash`, add: + +```ts +progress.skippedIds.push(parsed.id); +``` + +When parsed messages are fewer than 2, add: + +```ts +progress.skippedIds.push(parsed.id); +``` + +After a successful transaction, replace `progress.imported++` with: + +```ts +if (existingRow) { + progress.replaced++; + progress.replacedIds.push(parsed.id); + progress.summarizationCandidateIds.push(parsed.id); +} else { + progress.imported++; + progress.importedIds.push(parsed.id); + progress.summarizationCandidateIds.push(parsed.id); +} +``` + +In the catch block, add: + +```ts +progress.errorIds.push(meta.id); +``` + +- [ ] **Step 5: Run import tests** + +Run: + +```bash +npm run test -w server -- src/services/import.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add shared/types/index.ts server/src/services/import.ts server/src/services/import.test.ts server/src/routes/import.ts +git commit -m "feat: return structured local import ids" +``` + +### Task 3: Return Structured Remote Ingest IDs + +**Files:** +- Modify: `shared/types/index.ts` +- Modify: `server/src/services/ingest.ts` +- Modify: `server/src/services/ingest.test.ts` +- Modify: `server/src/services/remoteImport.ts` + +- [ ] **Step 1: Extend remote import response type** + +In `shared/types/index.ts`, extend `RemoteImportResponse` so it includes: + +```ts + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; +``` + +- [ ] **Step 2: Write failing remote ingest tests** + +Add to `server/src/services/ingest.test.ts`: + +```ts +test('ingestRemoteImport returns summary candidate ids only for imported and replaced items', () => { + resetDatabase(db); + + const first = remoteItem('first'); + const initial = ingest.ingestRemoteImport({ version: 1, items: [first] }); + assert.deepEqual(initial.importedIds, [first.conversationId]); + assert.deepEqual(initial.summarizationCandidateIds, [first.conversationId]); + + const skipped = ingest.ingestRemoteImport({ version: 1, items: [first] }); + assert.deepEqual(skipped.skippedIds, [first.conversationId]); + assert.deepEqual(skipped.summarizationCandidateIds, []); + + const changed = remoteItem('first', 'changed assistant response'); + const replaced = ingest.ingestRemoteImport({ version: 1, items: [changed] }); + assert.deepEqual(replaced.replacedIds, [first.conversationId]); + assert.deepEqual(replaced.summarizationCandidateIds, [first.conversationId]); +}); +``` + +- [ ] **Step 3: Run the failing ingest test** + +Run: + +```bash +npm run test -w server -- src/services/ingest.test.ts +``` + +Expected: FAIL because `RemoteImportResponse` lacks structured ID arrays. + +- [ ] **Step 4: Add ID arrays to `ingestRemoteImport()`** + +In `server/src/services/ingest.ts`, after `items` is built and before return: + +```ts + const importedIds = items + .filter((item) => item.status === 'imported') + .map((item) => item.conversationId); + const replacedIds = items + .filter((item) => item.status === 'replaced') + .map((item) => item.conversationId); + const skippedIds = items + .filter((item) => item.status === 'skipped') + .map((item) => item.conversationId); + const errorIds = items + .filter((item) => item.status === 'error') + .map((item) => item.conversationId) + .filter(Boolean); + const summarizationCandidateIds = [...importedIds, ...replacedIds]; +``` + +Add these fields to the return object: + +```ts + importedIds, + replacedIds, + skippedIds, + errorIds, + summarizationCandidateIds, +``` + +- [ ] **Step 5: Accumulate remote upload candidates** + +In `server/src/services/remoteImport.ts`, extend `RemoteImportResult`: + +```ts +export type RemoteImportResult = RemoteImportProgress & { + localErrors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; +}; +``` + +Initialize arrays in `runRemoteImport()`: + +```ts + importedIds: [], + replacedIds: [], + skippedIds: [], + errorIds: [], + summarizationCandidateIds: [], +``` + +After each `client.ingestConversations(...)` call: + +```ts + progress.importedIds.push(...result.importedIds); + progress.replacedIds.push(...result.replacedIds); + progress.skippedIds.push(...result.skippedIds); + progress.errorIds.push(...result.errorIds); + progress.summarizationCandidateIds.push(...result.summarizationCandidateIds); +``` + +- [ ] **Step 6: Run remote import and ingest tests** + +Run: + +```bash +npm run test -w server -- src/services/ingest.test.ts src/services/remoteImport.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add shared/types/index.ts server/src/services/ingest.ts server/src/services/ingest.test.ts server/src/services/remoteImport.ts server/src/services/remoteImport.test.ts +git commit -m "feat: return structured remote import ids" +``` + +### Task 4: Add Summarize-By-Ids APIs + +**Files:** +- Modify: `shared/types/index.ts` +- Modify: `server/src/routes/notes.ts` +- Create: `server/src/routes/notes-summarize-batch.test.ts` + +- [ ] **Step 1: Add shared summarize request/response types** + +Append to `shared/types/index.ts`: + +```ts +export interface SummarizeByIdsRequest { + conversationIds: string[]; +} + +export interface SummarizeByIdsResponse { + queued: number; + skipped: string[]; + unknown: string[]; +} + +export interface ConversationSummaryStatus { + id: string; + status: ConversationStatus | 'unknown'; +} +``` + +- [ ] **Step 2: Write failing route tests** + +Create `server/src/routes/notes-summarize-batch.test.ts`: + +```ts +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import Fastify from 'fastify'; + +const dataDir = mkdtempSync(join(tmpdir(), 'chatcrystal-summarize-route-test-')); +process.env.DATA_DIR = dataDir; + +const dbService = await import('../db/index.js'); +const { noteRoutes } = await import('./notes.js'); + +test('summarize by ids queues only requested imported conversations', async () => { + const db = await dbService.initDatabase(); + db.exec(` + PRAGMA foreign_keys = ON; + DELETE FROM experience_reviews; + DELETE FROM note_tags; + DELETE FROM embeddings; + DELETE FROM note_relations; + DELETE FROM notes; + DELETE FROM messages; + DELETE FROM conversations; + DELETE FROM import_log; + DELETE FROM vector_cleanup_tasks; + `); + db.run("INSERT INTO conversations (id, source, project_name, project_dir, message_count, first_message_at, last_message_at, file_path, file_size, file_mtime, status) VALUES (?, 'codex', 'p', 'p', 2, '2026-05-23', '2026-05-23', 'a', 1, 'm', 'imported')", ['new-id']); + db.run("INSERT INTO conversations (id, source, project_name, project_dir, message_count, first_message_at, last_message_at, file_path, file_size, file_mtime, status) VALUES (?, 'codex', 'p', 'p', 2, '2026-05-23', '2026-05-23', 'b', 1, 'm', 'summarized')", ['old-id']); + + const app = Fastify(); + await app.register(noteRoutes); + + const res = await app.inject({ + method: 'POST', + url: '/api/summarize/batch-ids', + payload: { conversationIds: ['new-id', 'old-id', 'missing-id'] }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.success, true); + assert.equal(body.data.queued, 1); + assert.deepEqual(body.data.skipped, ['old-id']); + assert.deepEqual(body.data.unknown, ['missing-id']); + + await app.close(); + dbService.closeDatabase(); + rmSync(dataDir, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 3: Run the failing route test** + +Run: + +```bash +npm run test -w server -- src/routes/notes-summarize-batch.test.ts +``` + +Expected: FAIL because `/api/summarize/batch-ids` does not exist. + +- [ ] **Step 4: Add `/api/summarize/batch-ids`** + +In `server/src/routes/notes.ts`, add a route after `/api/summarize/batch`: + +```ts + app.post('/api/summarize/batch-ids', async (req) => { + const { conversationIds } = req.body as { conversationIds?: string[] }; + const requested = [...new Set((conversationIds ?? []).filter((id) => typeof id === 'string' && id.trim()))]; + const db = getDatabase(); + const skipped: string[] = []; + const unknown: string[] = []; + let queued = 0; + + for (const id of requested) { + const r = db.exec('SELECT project_name, slug, status FROM conversations WHERE id = ?', [id]); + const row = r[0]?.values[0]; + if (!row) { + unknown.push(id); + continue; + } + + const [pn, sl, status] = row as [string, string | null, string]; + if (status === 'summarized' || taskTracker.isTaskActive(id)) { + skipped.push(id); + continue; + } + + const title = `${pn} / ${sl || id.slice(0, 8)}`; + enqueueWithRetry(id, title, () => triggerSummarize(id)).catch((err) => { + console.error(`[Summarize] Error for ${id}:`, err instanceof Error ? err.message : err); + }); + queued++; + } + + return { + success: true, + data: { queued, skipped, unknown, queue: getQueueStatus() }, + }; + }); +``` + +- [ ] **Step 5: Add status-by-ids endpoint** + +In `server/src/routes/notes.ts`, add: + +```ts + app.post('/api/summarize/status-ids', async (req) => { + const { conversationIds } = req.body as { conversationIds?: string[] }; + const requested = [...new Set((conversationIds ?? []).filter((id) => typeof id === 'string' && id.trim()))]; + const db = getDatabase(); + const items = requested.map((id) => { + const row = db.exec('SELECT status FROM conversations WHERE id = ?', [id])[0]?.values[0]; + return { id, status: row ? String(row[0]) : 'unknown' }; + }); + return { success: true, data: { items, queue: getQueueStatus() } }; + }); +``` + +- [ ] **Step 6: Run route tests** + +Run: + +```bash +npm run test -w server -- src/routes/notes-summarize-batch.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add shared/types/index.ts server/src/routes/notes.ts server/src/routes/notes-summarize-batch.test.ts +git commit -m "feat: add summarize by ids endpoints" +``` + +--- + +## Gate 2B: Web And Electron Trust Boundary + +### Task 5: Add Electron Cloud HTTP Auth Allowance + +**Files:** +- Create: `client/src/types/electron.d.ts` +- Modify: `client/src/lib/api.ts` +- Modify: `client/src/components/AuthGate.tsx` + +- [ ] **Step 1: Add browser global type** + +Create `client/src/types/electron.d.ts`: + +```ts +export {}; + +declare global { + interface Window { + chatcrystalElectronCloud?: { + allowInsecureHttpAuth: boolean; + origin: string; + }; + } +} +``` + +- [ ] **Step 2: Update API guard** + +In `client/src/lib/api.ts`, add: + +```ts +export function isElectronCloudHttpAuthAllowed(location = window.location): boolean { + const marker = window.chatcrystalElectronCloud; + if (!marker?.allowInsecureHttpAuth) return false; + return marker.origin === location.origin; +} +``` + +Replace `assertSafeWebAuthTransport()` with: + +```ts +export function assertSafeWebAuthTransport(): void { + if (!isInsecureRemoteHttpLocation()) return; + if (isElectronCloudHttpAuthAllowed()) return; + throw new Error( + "Refusing to send ChatCrystal access tokens over public HTTP. Use HTTPS or a local tunnel.", + ); +} +``` + +- [ ] **Step 3: Update AuthGate refresh and submit checks** + +In `client/src/components/AuthGate.tsx`, import: + +```ts + isElectronCloudHttpAuthAllowed, +``` + +Change both public HTTP checks to: + +```ts +if (getStoredToken() && isInsecureRemoteHttpLocation() && !isElectronCloudHttpAuthAllowed()) { +``` + +and: + +```ts +if (isInsecureRemoteHttpLocation() && !isElectronCloudHttpAuthAllowed()) { +``` + +- [ ] **Step 4: Build the client** + +Run: + +```bash +npm run build -w client +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/src/types/electron.d.ts client/src/lib/api.ts client/src/components/AuthGate.tsx +git commit -m "feat: allow electron cloud http auth session" +``` + +### Task 6: Add Electron State And MCP Snippet Builders + +**Files:** +- Create: `electron/state.ts` +- Create: `electron/mcp-snippets.ts` +- Modify: `electron/tsconfig.json` + +- [ ] **Step 1: Add Electron state helpers** + +Create `electron/state.ts`: + +```ts +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { app } from "electron"; + +export type ElectronMode = "unset" | "local" | "cloud"; + +export type ElectronOnboardingState = { + version: 1; + mode: ElectronMode; + defaultMode: Exclude | null; + cloudBaseUrl: string | null; + cloudToken: string | null; + importSkipped: boolean; + mcpSkipped: boolean; + summarizationBatchIds: string[]; + summarizationRequestId: string | null; + updatedAt: string; +}; + +export const DEFAULT_ELECTRON_STATE: ElectronOnboardingState = { + version: 1, + mode: "unset", + defaultMode: null, + cloudBaseUrl: null, + cloudToken: null, + importSkipped: false, + mcpSkipped: false, + summarizationBatchIds: [], + summarizationRequestId: null, + updatedAt: new Date(0).toISOString(), +}; + +export function getElectronStatePath(): string { + return path.join(app.getPath("userData"), "onboarding-state.json"); +} + +export function redactToken(value: string | null): string | null { + if (!value) return null; + if (value.length <= 8) return "••••"; + return `${value.slice(0, 4)}••••${value.slice(-4)}`; +} + +export function readElectronState(): ElectronOnboardingState { + try { + const parsed = JSON.parse(readFileSync(getElectronStatePath(), "utf-8")) as Partial; + if (parsed.version !== 1) return DEFAULT_ELECTRON_STATE; + return { + ...DEFAULT_ELECTRON_STATE, + ...parsed, + updatedAt: parsed.updatedAt ?? new Date().toISOString(), + }; + } catch { + return DEFAULT_ELECTRON_STATE; + } +} + +export function writeElectronState(next: ElectronOnboardingState): void { + const filePath = getElectronStatePath(); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify({ ...next, updatedAt: new Date().toISOString() }, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); +} +``` + +- [ ] **Step 2: Add MCP snippet builder** + +Create `electron/mcp-snippets.ts`: + +```ts +export type McpSnippetInput = + | { mode: "local"; baseUrl: string } + | { mode: "cloud"; baseUrl: string; token: string }; + +export function isNonLocalHttpUrl(baseUrl: string): boolean { + const url = new URL(baseUrl); + return url.protocol === "http:" && !["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); +} + +export function buildMcpSnippet(input: McpSnippetInput): Record { + const env: Record = { + CHATCRYSTAL_BASE_URL: input.baseUrl, + }; + if (input.mode === "cloud") { + env.CHATCRYSTAL_API_TOKEN = input.token; + if (isNonLocalHttpUrl(input.baseUrl)) { + env.CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP = "true"; + } + } + return { + command: "crystal", + args: ["mcp"], + env, + }; +} +``` + +- [ ] **Step 3: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add electron/state.ts electron/mcp-snippets.ts electron/tsconfig.json +git commit -m "feat: add electron state and mcp snippets" +``` + +### Task 7: Split Electron Preloads + +**Files:** +- Create: `electron/onboarding-preload.ts` +- Create: `electron/cloud-preload.ts` +- Modify: `electron/preload.ts` + +- [ ] **Step 1: Keep local preload minimal** + +Leave `electron/preload.ts` as the local renderer preload: + +```ts +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("electronAPI", { + isElectron: true, + versions: { + electron: process.versions.electron, + node: process.versions.node, + chrome: process.versions.chrome, + }, +}); +``` + +- [ ] **Step 2: Add cloud preload** + +Create `electron/cloud-preload.ts`: + +```ts +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("chatcrystalElectronCloud", { + allowInsecureHttpAuth: true, + origin: window.location.origin, +}); +``` + +This preload exposes no filesystem, import, config, token-read, or IPC method. + +- [ ] **Step 3: Add onboarding preload** + +Create `electron/onboarding-preload.ts`: + +```ts +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("chatcrystalOnboarding", { + getState: () => ipcRenderer.invoke("onboarding:get-state"), + saveCloudConnection: (input: { baseUrl: string; token: string }) => + ipcRenderer.invoke("onboarding:save-cloud-connection", input), + startLocal: () => ipcRenderer.invoke("onboarding:start-local"), + importLocalHistory: () => ipcRenderer.invoke("onboarding:import-local-history"), + uploadLocalHistory: () => ipcRenderer.invoke("onboarding:upload-local-history"), + testModel: (mode: "local" | "cloud") => ipcRenderer.invoke("onboarding:test-model", mode), + summarizeBatch: (conversationIds: string[]) => ipcRenderer.invoke("onboarding:summarize-batch", conversationIds), + getMcpSnippet: (mode: "local" | "cloud") => ipcRenderer.invoke("onboarding:get-mcp-snippet", mode), + openApp: (mode: "local" | "cloud") => ipcRenderer.invoke("onboarding:open-app", mode), + useTemporaryLocal: () => ipcRenderer.invoke("onboarding:use-temporary-local"), +}); +``` + +- [ ] **Step 4: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron/preload.ts electron/onboarding-preload.ts electron/cloud-preload.ts +git commit -m "feat: split electron preload boundaries" +``` + +### Task 8: Make Electron Main Mode-Aware + +**Files:** +- Modify: `electron/main.ts` +- Modify: `electron/tray.ts` + +- [ ] **Step 1: Add mode-aware window creation helpers** + +In `electron/main.ts`, replace `createWindow()` with a helper that accepts preload and minimum size: + +```ts +type WindowKind = "local" | "cloud" | "onboarding"; + +function createWindow(kind: WindowKind): BrowserWindow { + const state = loadWindowState(); + const iconPath = path.join(__dirname, "..", "icon.png"); + const preload = + kind === "cloud" + ? path.join(__dirname, "cloud-preload.js") + : kind === "onboarding" + ? path.join(__dirname, "onboarding-preload.js") + : path.join(__dirname, "preload.js"); + + const win = new BrowserWindow({ + width: state.width, + height: state.height, + x: state.x, + y: state.y, + minWidth: 900, + minHeight: 600, + show: false, + title: "ChatCrystal", + icon: iconPath, + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + attachWindowStateHandlers(win); + return win; +} +``` + +Move the existing resize/move/close handlers into `attachWindowStateHandlers(win)`. + +- [ ] **Step 2: Start local Core with watcher disabled** + +Change `startServer()` to pass the new option: + +```ts +type ServerModule = { + createServer: (opts?: { port?: number; host?: string; startWatcher?: boolean }) => Promise<{ + app: unknown; + port: number; + shutdown: () => Promise; + }>; +}; +``` + +Return: + +```ts +return serverModule.createServer({ port, host: "127.0.0.1", startWatcher: false }); +``` + +- [ ] **Step 3: Add exact-origin navigation lock** + +Add to `electron/main.ts`: + +```ts +function lockNavigationToOrigin(win: BrowserWindow, allowedOrigin: string): void { + const allow = (target: string) => { + try { + return new URL(target).origin === allowedOrigin; + } catch { + return false; + } + }; + + win.webContents.on("will-navigate", (event, targetUrl) => { + if (!allow(targetUrl)) event.preventDefault(); + }); + + win.webContents.setWindowOpenHandler(({ url }) => { + return allow(url) ? { action: "allow" } : { action: "deny" }; + }); +} +``` + +- [ ] **Step 4: Inject cloud token into localStorage** + +Add: + +```ts +async function injectCloudToken(win: BrowserWindow, baseUrl: string, token: string): Promise { + const expectedOrigin = new URL(baseUrl).origin; + const currentOrigin = new URL(win.webContents.getURL()).origin; + if (currentOrigin !== expectedOrigin) { + throw new Error("Refusing to inject token into a different origin"); + } + + await win.webContents.executeJavaScript( + `window.localStorage.setItem("chatcrystal.apiToken", ${JSON.stringify(token)}); + window.dispatchEvent(new Event("chatcrystal-auth-changed"));`, + ); +} +``` + +- [ ] **Step 5: Route startup by saved state** + +In `app.whenReady()`, read `readElectronState()` and branch: + +```ts +const state = readElectronState(); +if (state.defaultMode === "cloud" && state.cloudBaseUrl && state.cloudToken) { + mainWindow = createWindow("cloud"); + const origin = new URL(state.cloudBaseUrl).origin; + lockNavigationToOrigin(mainWindow, origin); + await mainWindow.loadURL(state.cloudBaseUrl); + await injectCloudToken(mainWindow, state.cloudBaseUrl, state.cloudToken); + createTray({ win: mainWindow, mode: "cloud", cloudBaseUrl: state.cloudBaseUrl }); + return; +} + +if (state.defaultMode === "local") { + await ensureLocalCoreStarted(); + mainWindow = createWindow("local"); + await mainWindow.loadURL(`http://localhost:${serverPort}`); + createTray({ win: mainWindow, mode: "local", localBaseUrl: `http://localhost:${serverPort}` }); + return; +} + +mainWindow = createWindow("onboarding"); +await mainWindow.loadURL(getOnboardingDataUrl()); +createTray({ win: mainWindow, mode: "onboarding" }); +``` + +Define `ensureLocalCoreStarted()` using existing port selection and `startServer(serverPort)`. + +- [ ] **Step 6: Make tray mode-aware** + +Change `electron/tray.ts` export signature: + +```ts +export type TrayOptions = + | { win: BrowserWindow; mode: "onboarding" } + | { win: BrowserWindow; mode: "local"; localBaseUrl: string } + | { win: BrowserWindow; mode: "cloud"; cloudBaseUrl: string }; + +export function createTray(options: TrayOptions): Tray { +``` + +Build menu entries: + +```ts +const openTarget = + options.mode === "cloud" + ? options.cloudBaseUrl + : options.mode === "local" + ? options.localBaseUrl + : null; +``` + +Only show "Search Knowledge" when `openTarget` exists, using `${openTarget}/search`. + +- [ ] **Step 7: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add electron/main.ts electron/tray.ts +git commit -m "feat: make electron shell mode aware" +``` + +--- + +## Gate 2C: Onboarding UI, Import/Upload, MCP Helper + +### Task 9: Add Onboarding Page And IPC Handlers + +**Files:** +- Create: `electron/onboarding-page.ts` +- Create: `electron/ipc.ts` +- Modify: `electron/main.ts` + +- [ ] **Step 1: Add self-contained onboarding page** + +Create `electron/onboarding-page.ts`: + +```ts +export function getOnboardingDataUrl(): string { + const html = ` + + + + + ChatCrystal Onboarding + + + +

正在唤醒您的超级大脑

+ + +`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} +``` + +- [ ] **Step 2: Add guarded IPC handlers** + +Create `electron/ipc.ts`: + +```ts +import { ipcMain, type IpcMainInvokeEvent } from "electron"; + +export type IpcDeps = { + getOnboardingOrigin: () => string; + saveCloudConnection(input: { baseUrl: string; token: string }): Promise; + startLocal(): Promise; + importLocalHistory(): Promise; + uploadLocalHistory(): Promise; + testModel(mode: "local" | "cloud"): Promise; + summarizeBatch(ids: string[]): Promise; + getMcpSnippet(mode: "local" | "cloud"): Promise; + openApp(mode: "local" | "cloud"): Promise; + useTemporaryLocal(): Promise; +}; + +function assertOnboardingSender(event: IpcMainInvokeEvent, expectedOrigin: string): void { + if (event.senderFrame.origin !== expectedOrigin) { + throw new Error("Rejected onboarding IPC from unexpected origin"); + } +} + +export function registerOnboardingIpc(deps: IpcDeps): void { + const guard = (event: IpcMainInvokeEvent) => assertOnboardingSender(event, deps.getOnboardingOrigin()); + ipcMain.handle("onboarding:save-cloud-connection", (event, input) => { guard(event); return deps.saveCloudConnection(input); }); + ipcMain.handle("onboarding:start-local", (event) => { guard(event); return deps.startLocal(); }); + ipcMain.handle("onboarding:import-local-history", (event) => { guard(event); return deps.importLocalHistory(); }); + ipcMain.handle("onboarding:upload-local-history", (event) => { guard(event); return deps.uploadLocalHistory(); }); + ipcMain.handle("onboarding:test-model", (event, mode) => { guard(event); return deps.testModel(mode); }); + ipcMain.handle("onboarding:summarize-batch", (event, ids) => { guard(event); return deps.summarizeBatch(ids); }); + ipcMain.handle("onboarding:get-mcp-snippet", (event, mode) => { guard(event); return deps.getMcpSnippet(mode); }); + ipcMain.handle("onboarding:open-app", (event, mode) => { guard(event); return deps.openApp(mode); }); + ipcMain.handle("onboarding:use-temporary-local", (event) => { guard(event); return deps.useTemporaryLocal(); }); +} +``` + +- [ ] **Step 3: Wire page and IPC into main** + +In `electron/main.ts`, import: + +```ts +import { getOnboardingDataUrl } from "./onboarding-page"; +import { registerOnboardingIpc } from "./ipc"; +import { buildMcpSnippet } from "./mcp-snippets"; +import { readElectronState, writeElectronState } from "./state"; +``` + +Register IPC after `app.whenReady()` starts and before loading onboarding. For this first implementation, `saveCloudConnection` should verify with `/api/setup/status` and `/api/auth/verify` using `fetch`, then write Electron state. + +- [ ] **Step 4: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron/onboarding-page.ts electron/ipc.ts electron/main.ts +git commit -m "feat: add electron onboarding shell" +``` + +### Task 10: Implement Explicit Import And Upload IPC + +**Files:** +- Modify: `electron/main.ts` +- Modify: `server/src/services/remoteImport.ts` +- Modify: `server/src/cli/client.ts` if a reusable remote client helper is needed. + +- [ ] **Step 1: Implement local import IPC** + +In `electron/main.ts`, implement `importLocalHistory` by ensuring local Core is running and calling: + +```ts +const response = await fetch(`http://localhost:${serverPort}/api/import/scan`, { + method: "POST", +}); +const body = await response.json(); +if (!body.success) throw new Error(body.error || "Local import failed"); +return body.data; +``` + +- [ ] **Step 2: Implement cloud upload IPC** + +In `electron/main.ts`, implement `uploadLocalHistory` by importing server remote import modules from the packaged server entry and passing a small client: + +```ts +const connection = readElectronState(); +if (!connection.cloudBaseUrl || !connection.cloudToken) { + throw new Error("Cloud connection is not configured"); +} +const remoteImport = await importServerModule("services/remoteImport.js"); +return remoteImport.runRemoteImport({ + ingestConversations: async (request) => { + const response = await fetch(`${connection.cloudBaseUrl}/api/import/ingest`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${connection.cloudToken}`, + }, + body: JSON.stringify(request), + }); + const body = await response.json(); + if (!body.success) throw new Error(body.error || "Cloud upload failed"); + return body.data; + }, +}); +``` + +Use the actual packaged module path helper from `startServer()` style dynamic import. + +- [ ] **Step 3: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add electron/main.ts server/src/services/remoteImport.ts +git commit -m "feat: wire electron import and upload actions" +``` + +### Task 11: Add Model Test, Summarize, And Error Recovery Wiring + +**Files:** +- Modify: `electron/main.ts` +- Modify: `electron/onboarding-page.ts` +- Modify: `client/src/lib/api.ts` if response typing is needed. + +- [ ] **Step 1: Implement active Core API helper** + +Add in `electron/main.ts`: + +```ts +function getActiveCoreBaseUrl(mode: "local" | "cloud"): string { + if (mode === "local") return `http://localhost:${serverPort}`; + const state = readElectronState(); + if (!state.cloudBaseUrl) throw new Error("Cloud URL is not configured"); + return state.cloudBaseUrl; +} + +function getActiveCoreHeaders(mode: "local" | "cloud"): Record { + if (mode === "local") return {}; + const state = readElectronState(); + if (!state.cloudToken) throw new Error("Cloud token is not configured"); + return { Authorization: `Bearer ${state.cloudToken}` }; +} +``` + +- [ ] **Step 2: Implement model test IPC** + +Use: + +```ts +const response = await fetch(`${getActiveCoreBaseUrl(mode)}/api/config/test`, { + method: "POST", + headers: getActiveCoreHeaders(mode), +}); +``` + +Return the parsed `data` object. + +- [ ] **Step 3: Implement summarize-by-ids IPC** + +Use: + +```ts +const response = await fetch(`${getActiveCoreBaseUrl(mode)}/api/summarize/batch-ids`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getActiveCoreHeaders(mode), + }, + body: JSON.stringify({ conversationIds }), +}); +``` + +Persist `summarizationBatchIds` and a timestamp request ID before the request. + +- [ ] **Step 4: Update onboarding page sequence** + +After import/upload result, use: + +```js +const ids = result.summarizationCandidateIds || []; +const model = await api.testModel(mode); +if (ids.length > 0 && model.llm.connected && model.embedding.connected) { + app.innerHTML = "

将对话结晶成记忆?

模型已连接,可以现在总结本次新导入的内容。

"; + document.getElementById("yes").onclick = async () => { await api.summarizeBatch(ids); renderMcp(mode); }; + document.getElementById("no").onclick = () => renderMcp(mode); +} else { + renderMcp(mode); +} +``` + +- [ ] **Step 5: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add electron/main.ts electron/onboarding-page.ts +git commit -m "feat: add onboarding model and summarize flow" +``` + +### Task 12: Verification And Packaging + +**Files:** +- Modify only files needed by failing checks. + +- [ ] **Step 1: Run server tests** + +Run: + +```bash +npm run test -w server +``` + +Expected: PASS. + +- [ ] **Step 2: Run client build** + +Run: + +```bash +npm run build -w client +``` + +Expected: PASS. + +- [ ] **Step 3: Compile Electron** + +Run: + +```bash +tsc -p electron/tsconfig.json +``` + +Expected: PASS. + +- [ ] **Step 4: Run full build** + +Run: + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 5: Pack Electron** + +Run: + +```bash +npm run pack:electron +``` + +Expected: PASS and `release/win-unpacked/ChatCrystal.exe` exists. + +- [ ] **Step 6: Commit verification fixes** + +If any verification fixes were required: + +```bash +git add +git commit -m "fix: complete phase 2 onboarding verification" +``` + +If no fixes were required, do not create an empty commit. + +--- + +## Review Checkpoints + +Request independent `gpt-5.5` + `xhigh` review after each gate: + +- After Gate 2A: focus on Core contracts, watcher disablement, structured IDs, summarize-by-ids. +- After Gate 2B: focus on Electron/Web trust boundaries, cloud preload safety, exact-origin token injection, public HTTP behavior. +- After Gate 2C: focus on onboarding UX completeness, MCP snippets, cloud/local routing, packaging. + +High-risk findings must be fixed before moving to the next gate. + +## Self-Review + +Spec coverage: + +- Local/cloud mode choice: Gate 2B and Gate 2C. +- Cloud Web UI loading and token localStorage key: Gate 2B. +- No preview/count layer: Gate 2C import/upload action. +- Watcher disabled by default in Electron Phase 2: Gate 2A and Gate 2B. +- Structured import/upload IDs and current-batch summarization: Gate 2A and Gate 2C. +- MCP snippets: Gate 2B and Gate 2C. +- Plain JSON token constraints: Gate 2B. + +Plan hygiene scan: + +- No unresolved work markers. +- No undefined deferred implementation slots. +- Each task has concrete files, commands, and expected outcomes. + +Type consistency: + +- `summarizationCandidateIds` is used consistently in shared types, import results, remote ingest results, and Electron summarize flow. +- `CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP` is used only for non-local HTTP MCP snippets. +- `chatcrystal.apiToken` and `chatcrystal-auth-changed` match the existing Web client constants. diff --git a/docs/superpowers/specs/2026-05-23-electron-onboarding-phase2-design.md b/docs/superpowers/specs/2026-05-23-electron-onboarding-phase2-design.md new file mode 100644 index 0000000..820d99f --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-electron-onboarding-phase2-design.md @@ -0,0 +1,473 @@ +# Phase 2 Electron Onboarding Design + +## Summary + +Phase 2 adds a Windows-first Electron onboarding module for ChatCrystal. The goal is to make the desktop app a complete first-run experience for both local mode and cloud mode, while keeping Phase 2 scoped enough to ship with the npm remote-mode release as `0.5.0`. + +The Electron app remains a single installer with two modes: + +- **Local memory library**: start the embedded local Core and use the shared local data directory. +- **Cloud super brain**: connect to an existing cloud Core, then load the cloud Web UI directly. + +Onboarding is not a one-time dialog. It is a reusable module for first launch, switching modes, connecting to a new cloud Core, retrying failed connections, importing local histories, and showing MCP configuration snippets. + +## Product Decisions + +### Installer And Platform Scope + +- Ship one ChatCrystal Electron installer. +- Do not split the app into local and cloud editions. +- Phase 2 targets Windows x64 first, matching the existing `electron-builder.yml` target. +- macOS and Linux desktop packages are out of scope for Phase 2. + +### First Screen + +The first onboarding screen must ask the user to choose the memory core: + +- **Local memory library** + - Starts the embedded local Core. + - Uses the existing shared data directory: `~/.chatcrystal/data`. + - Keeps CLI, MCP, and Electron local mode on the same local source of truth. + +- **Connect super brain** + - Asks for cloud URL and API token. + - Connects to the cloud Core. + - Later loads the cloud Web UI directly. + +The UI style is a focused wizard. It should be emotionally resonant but still utilitarian. Suitable connection copy includes: + +- `正在连接到您的超级大脑` +- `正在唤醒云端记忆核心` +- `正在验证记忆网络` +- `已连接到超级大脑` + +### Cloud Web UI Loading + +Cloud mode loads the cloud Web UI directly in the Electron window. Electron does not load a local copy of the SPA and point its API base URL at the cloud. + +Reasons: + +- The cloud Core already serves the complete Web UI. +- Cloud UI updates do not require updating the Electron package. +- It avoids duplicating API-base routing and CORS logic in the desktop shell. + +Trust boundary rules: + +- The cloud Web UI must not receive the high-privilege onboarding preload. +- The cloud Web UI must not be able to call local source scanning, local parsing, local import, cloud upload, shell config writes, or token-read IPC. +- Use a separate onboarding `webContents`/window/session, or recreate the main window when switching between onboarding/local app and cloud Web UI. Do not reuse a privileged preload for arbitrary remote content. +- Cloud navigation is locked to the exact saved cloud origin. Block or externalize unexpected navigation and `window.open` targets. + +### Cloud Auto Login + +Electron stores the cloud URL and API token, verifies them, and then writes the token into the saved cloud Web UI origin before loading the cloud UI. + +Rules: + +- When saving a new cloud connection, verify the cloud URL and token first via public/private cloud API calls. If verification fails, stay in onboarding and show a recoverable error. +- On startup with a saved cloud connection, verify reachability and token validity before loading the cloud UI. If verification fails, show the Electron cloud connection error page. +- Write the token to the cloud Web UI's `localStorage` key `chatcrystal.apiToken` only for the exact saved origin, for both HTTP and HTTPS cloud URLs. +- After writing the token, trigger the existing `chatcrystal-auth-changed` event or an equivalent reload/refresh path so the Web UI observes the login state immediately. +- Do not use Electron header injection as the normal login path. The cloud Web UI should behave like a regular logged-in session inside Electron. +- Add an Electron-specific allowance for the existing Web HTTP guard, scoped to the Electron cloud session and exact saved origin. Normal browser behavior must not change. +- If verification succeeds but token injection fails, load the cloud Web UI normally and let its existing auth gate handle login. +- Never inject the token into an arbitrary navigation target. + +### HTTP Handling + +HTTPS is recommended for cloud mode, but Phase 2 does not block HTTP. + +Rules: + +- `https://...`: normal recommended path. +- `http://localhost` and `http://127.0.0.1`: allowed for local tunnels and local testing. +- Non-local `http://...`: allowed with an inline recommendation to use HTTPS, not a blocking confirmation. +- Keep the copy light: recommend HTTPS as safer for public deployments, but do not pressure the user with a second confirmation. +- Electron cloud mode uses the same token-localStorage behavior for HTTP and HTTPS so the user gets one consistent login experience. +- MCP snippets for non-local HTTP targets must include `CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP=true` automatically so `crystal mcp` works with the chosen URL. Do not add a second scary confirmation in the MCP step. + +### Local And Cloud Import + +Both local and cloud modes receive a complete import onboarding path. + +Local mode flow: + +1. Start embedded local Core. +2. Show `正在唤醒本机记忆核心`. +3. Show an import action for local AI conversation history. +4. If the user starts import, scan, parse, and import into the local database as one complete operation. +5. Test model connectivity. +6. Offer summarization if model connectivity passes. +7. Enter the local Web UI. + +Cloud mode flow: + +1. Verify cloud Core and token. +2. Show `正在连接到您的超级大脑`. +3. Show an upload action for local AI conversation history. +4. If the user starts upload, scan, parse locally, and upload normalized payloads to the cloud ingest API as one complete operation. +5. Test cloud model connectivity. +6. Offer summarization if model connectivity passes. +7. Enter the cloud Web UI. + +Supported sources are the same five Phase 1 sources: + +- Claude Code +- Codex CLI +- Cursor +- Trae +- GitHub Copilot + +Phase 2 does not include a preview/count layer. Avoid half-complete source discovery UI such as `count: unknown`; if a polished preview cannot be implemented, do not show one. + +Import/upload service contract: + +- Import or upload starts only after a deliberate user action. +- Once started, the operation may scan, parse, and write/upload because the user has already chosen to import local history. +- Local import should reuse core import logic and return structured result fields: `importedIds`, `replacedIds`, `skippedIds`, `errorIds`, and `summarizationCandidateIds`. +- `summarizationCandidateIds` includes only conversations newly imported or content-replaced by the current confirmed operation. Skipped existing conversations are excluded by default. +- Cloud import should reuse Phase 1 remote item construction, chunking, ingest validation, and dedupe. Electron must not reimplement source parsing. +- Cloud ingest/upload should return the same structured ID categories when possible. If a server response cannot return IDs for a category yet, Phase 2 must add that contract before wiring onboarding summarization. + +### Summarization Prompt + +After import completes, Electron tests the active Core's LLM and embedding connectivity. + +Rules: + +- Do not rely only on configured provider fields. +- Run an actual connection test. +- If both required model paths are usable, ask whether to generate summaries now. +- If model connectivity is not usable, do not offer immediate summarization. +- Show a message such as: `本机历史已导入。配置可用的模型后,即可将对话结晶成记忆。` +- Provide an entry point to model settings. +- The onboarding prompt summarizes only the current confirmed import batch by default. +- If implementation reuses the existing all-unsummarized batch endpoint, it must add an explicit "all unsummarized conversations" choice. Do not silently queue old backlog during onboarding. +- Preferred implementation is a batch-by-ids API using `summarizationCandidateIds` returned from the confirmed import/upload result. +- Skipped existing conversations are not summarized by default, even if they are unsummarized. A separate explicit option may offer to summarize skipped-but-unsummarized conversations, with copy that distinguishes them from newly imported memory. +- Persist the current onboarding summarization batch IDs and a local request ID in Electron state before queueing work. +- Add or extend APIs so Electron can query summarization status for specific conversation IDs after restart. Do not rely only on the process-memory queue tracker. +- If the embedded Core restarts with conversations left in `status = 'summarizing'`, onboarding must present resume/retry/skip for the persisted current batch. It must not automatically call the all-unsummarized endpoint. + +### MCP Helper + +MCP Helper is the final onboarding step. + +Phase 2 MCP Helper scope: + +- Show `npm install -g chatcrystal`. +- Explain that AI tools usually start MCP automatically from their MCP config. +- Generate copy-ready MCP snippets for the supported AI tools whose MCP config format is known: Codex, Claude Code, Cursor, Trae, and VS Code/GitHub Copilot where applicable. +- Cloud snippets include: + - `command: crystal` + - `args: ["mcp"]` + - `CHATCRYSTAL_BASE_URL` + - `CHATCRYSTAL_API_TOKEN` + - `CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP=true` when the saved cloud URL is non-local HTTP. +- Local snippets include: + - `command: crystal` + - `args: ["mcp"]` + - `CHATCRYSTAL_BASE_URL` +- Do not require `crystal connect`. +- Do not automatically write AI tool config files. +- Do not automatically start MCP. +- Do not bundle a dedicated `chatcrystal-mcp.exe` in Phase 2. +- Do not automatically install npm CLI in Phase 2. + +Cloud snippets include the token in plain text because the goal is copy-ready configuration. The UI must label this clearly: the snippet contains the user's access token and should only be copied into trusted AI tools. Local snippets do not include a token unless local auth is introduced in a future phase. + +Snippet source-of-truth: + +- Cloud mode snippets always include `CHATCRYSTAL_BASE_URL` and `CHATCRYSTAL_API_TOKEN` from the saved Electron cloud connection. +- Local mode snippets always include `CHATCRYSTAL_BASE_URL` with the active local Core URL. This prevents `crystal mcp` from silently using a saved cloud connection from `~/.chatcrystal/client.json`. +- If the embedded local Core is not on `3721`, the snippet must use the actual port and clearly state that MCP depends on this Electron instance staying open. +- Electron does not assume `crystal mcp` can read Electron `userData`. CLI/MCP saved connection in `~/.chatcrystal/client.json` is a separate Phase 1 mechanism and is not silently synchronized in Phase 2. +- A future "sync Electron cloud connection to CLI" action can be added later, but Phase 2 keeps snippets copy-ready instead of mutating CLI config. + +## Architecture + +### Startup Matrix + +Electron startup must choose the active core before starting services: + +| Saved state | Embedded local Core | Window target | Notes | +| --- | --- | --- | --- | +| No onboarding/default mode | Not started | Onboarding Renderer | First screen is local/cloud choice. | +| Local mode | Started | Local Web UI | Uses `~/.chatcrystal/data`. | +| Cloud mode | Not started | Cloud Web UI | Verify cloud URL/token first. | +| Cloud failure recovery | Not started | Electron error page | Retry/edit/open cloud login/temporary local actions. | +| Temporary local recovery | Started | Local Web UI with clear temporary label | Does not change saved default mode. | + +Tray and menu actions must route through the active mode. In cloud mode, search/open actions target the cloud Web UI; local-only actions are hidden or relabeled. They must not silently jump the user back to a local memory library. + +### Watcher During Onboarding + +The existing local server watcher auto-imports changed files. During Electron onboarding this would bypass the user's explicit import action, so it is disabled by default. + +Rules: + +- Add a server option or environment switch such as `createServer({ startWatcher: false })` for Electron onboarding. +- Local onboarding may start the embedded Core for API access, but Electron Phase 2 keeps the auto-import watcher off by default even after onboarding reaches `done`. +- The watcher starts only after the user explicitly enables automatic import/sync in a dedicated control. +- While onboarding is active, file changes must not call `importAll()`. +- Cloud mode does not start the embedded local Core and therefore does not start the local watcher. +- Tests should cover that the explicit onboarding import/upload action remains the only import path while onboarding is incomplete. + +### Modules + +#### Electron Shell + +Responsible for: + +- Choosing whether to show onboarding, local Web UI, cloud Web UI, or an error page. +- Starting and stopping the embedded local Core only when local mode or temporary local recovery requires it. +- Loading the cloud Web UI after successful cloud verification. +- Handling tray/menu entries for continuing onboarding, importing histories, MCP helper, and switching modes. +- Displaying a cloud connection error page with recovery actions. + +#### Onboarding Renderer + +An Electron-specific renderer page. It does not depend on the local Core already running. + +Responsible for: + +- Mode choice. +- Cloud connection form. +- Connection progress screens. +- Import/upload action. +- Import progress. +- Model connectivity result. +- Summarization prompt. +- MCP helper snippets. +- Skip/continue controls. + +The Onboarding Renderer should be a real page, not a sequence of native dialogs. + +#### Preload / IPC Bridge + +Renderer code must not directly read local files, mutate configuration, or call parser services. Preload exposes a narrow API over IPC. + +High-privilege preload is available only to the Electron-owned onboarding/local renderer. Remote cloud Web UI content gets no local filesystem/import/config IPC. + +Required API surface: + +- Read and write Electron onboarding state. +- Read and write cloud connection config. +- Verify cloud Core URL and token. +- Start local Core. +- Run explicit local history import to local Core. +- Run explicit local history upload to cloud Core. +- Test model connectivity for the active Core. +- Trigger batch summarization for the active Core. +- Generate MCP snippets. +- Open local or cloud Web UI. +- Clear or repair cloud connection state. + +IPC guard rules: + +- Every high-privilege `ipcMain` handler checks `event.senderFrame.origin`, the current onboarding state, and the active mode before doing work. +- Import/upload IPC is callable only from the onboarding renderer while the state machine is in a matching import state. +- Cloud Web UI cannot call import/upload IPC even when it is loaded inside Electron. +- Navigation and external-open handlers enforce the exact saved cloud origin for cloud mode. + +#### Core Reuse Layer + +Phase 2 must reuse Phase 1 server/core services: + +- Source adapters. +- Local import logic. +- Remote import item construction. +- Remote upload chunking. +- Cloud ingest validation and dedupe. +- Config connection testing. +- Summarization queue/API. +- New or extended summarize-by-ids API for onboarding's current import batch. + +Do not reimplement parsing in Electron. + +Cloud upload may reuse parser/import payload code from the server package inside Electron main, but it must not start the local Fastify Core or write to the local database just to upload to cloud. + +### Storage + +Electron `app.getPath("userData")` stores shell-only state: + +- Window state. +- Onboarding state. +- Default mode. +- Cloud URL and token. +- Step completion/skipped flags. + +On Windows with the current app identity, this resolves to `%APPDATA%\ChatCrystal`. + +Cloud token storage is plain JSON for Phase 2. This is an intentional product decision: users can inspect, change, and copy the token easily, and MCP snippets can be generated without credential-manager coupling. + +Plaintext-token constraints: + +- Store the token only in Electron `userData`, not in ChatCrystal's conversation database. +- Write files with restrictive permissions where supported. +- Redact tokens from logs, status views, diagnostics, screenshots, and error reports. +- Hide the token in normal UI, but allow deliberate reveal/copy in cloud settings and MCP snippet flows. +- Label the risk clearly: anyone who can read the user's local Electron config can use the cloud token. + +ChatCrystal data remains separate: + +- Local mode source of truth: `~/.chatcrystal/data`. +- Cloud mode source of truth: cloud Core `/data` volume. + +Electron `userData` must not become a second database source of truth. + +## Onboarding State Machine + +The implementation should use an explicit state machine or an equivalent reducer-driven model with named states. + +Core states: + +- `mode-choice` +- `cloud-connect` +- `connecting-cloud` +- `starting-local` +- `connection-error` +- `import-choice` +- `importing` +- `import-complete` +- `model-test` +- `summarize-prompt` +- `summarizing` +- `mcp-helper` +- `done` + +Rules: + +- Mode choice cannot be skipped. +- Cloud users cannot enter cloud Web UI until cloud connection succeeds. If saved-token verification fails, they see the Electron recovery page first; opening the cloud Web UI login page is an explicit recovery action. +- Local users cannot enter local Web UI until local Core starts successfully. +- Import/upload can be skipped. +- Summarization can be skipped. +- MCP Helper can be skipped. +- Skipped steps remain available from menu/settings. +- Onboarding state persists enough to resume after closing the app. +- If the app closes during `importing`, restart resumes to a safe retry state, not a stuck progress screen. The user can retry the confirmed import/upload action. +- If the app closes during `summarizing`, restart loads the persisted onboarding batch IDs/request ID, queries per-conversation status from the active Core, and shows resume/retry/skip. It must not blindly enqueue the same batch again or call the all-unsummarized endpoint. + +## Error Handling + +### Token Invalid + +Trigger: cloud API returns unauthorized for saved token. + +Copy: + +- `访问 token 已失效` +- `云端核心拒绝了当前 token` + +Actions: + +- Re-enter token. +- Clear cloud connection. +- Open cloud login page. + +### Cloud Unreachable + +Trigger: DNS, TLS, refused connection, timeout, or server unavailable. + +Copy: + +- `暂时无法抵达您的超级大脑` + +Actions: + +- Retry. +- Edit URL. +- Temporarily use local mode. + +Temporary local mode must not silently change the default mode. It should be clear that the user is currently using local mode, not the cloud super brain. + +### Target Is Not Cloud Mode + +Trigger: server status does not report cloud mode when saving a cloud connection. + +Copy: + +- `这个地址不是云端 Core` + +Actions: + +- Edit URL. +- Review Docker cloud deployment docs. + +### HTTP Warning + +Trigger: non-local HTTP URL. + +Behavior: + +- Show inline warning only. +- Do not block. +- Recommend HTTPS for public deployments. + +### No Local Sources Found + +Copy: + +- `还没有发现可导入的本机记忆源` + +Actions: + +- Skip. +- Retry import/upload. +- View supported sources. + +### Partial Source Read Failure + +Copy: + +- `部分记忆源暂时无法读取` + +Actions: + +- Continue with readable sources. +- View details. +- Retry. + +### Model Connectivity Unavailable + +Copy: + +- `模型连接尚不可用` + +Actions: + +- Go to settings. +- Skip summarization. +- Enter ChatCrystal. + +## Out Of Scope For Phase 2 + +- Multi-user accounts. +- Enterprise credential management. +- Electron-bundled MCP executable. +- Automatic npm CLI installation. +- Automatic MCP config file writes. +- macOS/Linux Electron packages. +- Cloud server auto-provisioning. +- Multiple cloud profile management. +- Offline sync or conflict resolution. +- A new model-provider setup wizard. + +## Release Plan + +Do not publish a standalone npm release for Phase 1 remote mode. Phase 2 completion should ship as `0.5.0`, covering: + +- Docker/GHCR cloud deployment. +- Electron onboarding. +- Electron local and cloud modes. +- CLI/npm remote mode. +- MCP remote configuration snippets. +- Local parsing with local or cloud import targets. + +## Open Implementation Notes + +- Keep the Onboarding Renderer visually focused. The first screen must still show the local/cloud choice. +- Keep copy emotional at moments of transition, not throughout the whole UI. +- Prefer direct, actionable error recovery over generic failure messages. +- Reuse Phase 1 tests where possible and add Electron-focused tests around state transitions, config persistence, and IPC boundaries. diff --git a/electron/cloud-preload.ts b/electron/cloud-preload.ts new file mode 100644 index 0000000..ba0efd3 --- /dev/null +++ b/electron/cloud-preload.ts @@ -0,0 +1,6 @@ +import { contextBridge } from "electron"; + +contextBridge.exposeInMainWorld("chatcrystalElectronCloud", { + allowInsecureHttpAuth: true, + origin: window.location.origin, +}); diff --git a/electron/ipc.ts b/electron/ipc.ts new file mode 100644 index 0000000..8998124 --- /dev/null +++ b/electron/ipc.ts @@ -0,0 +1,95 @@ +import { ipcMain, type IpcMainInvokeEvent } from "electron"; + +export type IpcDeps = { + getOnboardingOrigin: () => string; + getOnboardingUrl: () => string; + getState(): Promise | unknown; + saveCloudConnection(input: { baseUrl: string; token: string }): Promise; + startLocal(): Promise; + importLocalHistory(): Promise; + uploadLocalHistory(): Promise; + testModel(mode: "local" | "cloud"): Promise; + summarizeBatch(input: { mode: "local" | "cloud"; conversationIds: string[] }): Promise; + getMcpSnippet(mode: "local" | "cloud"): Promise; + openApp(mode: "local" | "cloud"): Promise; + useTemporaryLocal(): Promise; +}; + +function assertOnboardingSender( + event: IpcMainInvokeEvent, + expectedOrigin: string, + expectedUrl: string, +): void { + if ( + !event.senderFrame || + event.senderFrame.origin !== expectedOrigin || + event.senderFrame.url !== expectedUrl + ) { + throw new Error("Rejected onboarding IPC from unexpected origin"); + } +} + +function assertMode(value: unknown): asserts value is "local" | "cloud" { + if (value !== "local" && value !== "cloud") { + throw new Error("Invalid onboarding mode"); + } +} + +function assertConversationIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((id): id is string => typeof id === "string" && id.trim().length > 0); +} + +export function registerOnboardingIpc(deps: IpcDeps): void { + const guard = (event: IpcMainInvokeEvent) => + assertOnboardingSender(event, deps.getOnboardingOrigin(), deps.getOnboardingUrl()); + + ipcMain.handle("onboarding:get-state", (event) => { + guard(event); + return deps.getState(); + }); + ipcMain.handle("onboarding:save-cloud-connection", (event, input) => { + guard(event); + return deps.saveCloudConnection(input); + }); + ipcMain.handle("onboarding:start-local", (event) => { + guard(event); + return deps.startLocal(); + }); + ipcMain.handle("onboarding:import-local-history", (event) => { + guard(event); + return deps.importLocalHistory(); + }); + ipcMain.handle("onboarding:upload-local-history", (event) => { + guard(event); + return deps.uploadLocalHistory(); + }); + ipcMain.handle("onboarding:test-model", (event, mode) => { + guard(event); + assertMode(mode); + return deps.testModel(mode); + }); + ipcMain.handle("onboarding:summarize-batch", (event, input) => { + guard(event); + const payload = input as { mode?: unknown; conversationIds?: unknown }; + assertMode(payload?.mode); + return deps.summarizeBatch({ + mode: payload.mode, + conversationIds: assertConversationIds(payload.conversationIds), + }); + }); + ipcMain.handle("onboarding:get-mcp-snippet", (event, mode) => { + guard(event); + assertMode(mode); + return deps.getMcpSnippet(mode); + }); + ipcMain.handle("onboarding:open-app", (event, mode) => { + guard(event); + assertMode(mode); + return deps.openApp(mode); + }); + ipcMain.handle("onboarding:use-temporary-local", (event) => { + guard(event); + return deps.useTemporaryLocal(); + }); +} diff --git a/electron/main.ts b/electron/main.ts index ba2917c..351d263 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,5 @@ import { + existsSync, mkdirSync, readFileSync, writeFileSync, @@ -8,24 +9,26 @@ import { homedir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { app, BrowserWindow, dialog, Menu, screen, session } from "electron"; +import { registerOnboardingIpc } from "./ipc"; +import { buildMcpSnippet } from "./mcp-snippets"; +import { getOnboardingDataUrl } from "./onboarding-page"; +import { + readElectronState, + redactToken, + writeElectronState, + type ElectronOnboardingState, +} from "./state"; import { createTray, destroyTray } from "./tray"; -// -------------------------------------------------- -// Single instance lock -// -------------------------------------------------- const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.quit(); } -// -------------------------------------------------- -// Remove default menu bar -// -------------------------------------------------- Menu.setApplicationMenu(null); -// -------------------------------------------------- -// Window state persistence -// -------------------------------------------------- +type WindowMode = "onboarding" | "local" | "cloud"; + interface WindowState { x?: number; y?: number; @@ -34,6 +37,42 @@ interface WindowState { isMaximized: boolean; } +type ServerInstance = { + app: unknown; + port: number; + shutdown: () => Promise; +}; + +type ServerModule = { + createServer: (opts?: { + port?: number; + host?: string; + startWatcher?: boolean; + }) => Promise; +}; + +type RemoteImportModule = { + runRemoteImport: (client: { + ingestConversations: (request: unknown) => Promise; + }) => Promise; +}; + +type ApiEnvelope = + | { success: true; data: T } + | { success: false; error?: string }; + +const ONBOARDING_ORIGIN = "null"; +const API_TOKEN_LOCAL_STORAGE_KEY = "chatcrystal.apiToken"; +const AUTH_CHANGED_EVENT = "chatcrystal-auth-changed"; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +let mainWindow: BrowserWindow | null = null; +let serverShutdown: (() => Promise) | null = null; +let isQuitting = false; +let serverPort = 3721; +let lastNormalBounds: Electron.Rectangle | null = null; +let currentOnboardingUrl = ""; + function getWindowStatePath(): string { return path.join(app.getPath("userData"), "window-state.json"); } @@ -41,7 +80,7 @@ function getWindowStatePath(): string { function loadWindowState(): WindowState { try { const data = readFileSync(getWindowStatePath(), "utf-8"); - return JSON.parse(data); + return JSON.parse(data) as WindowState; } catch { return { width: 1280, height: 800, isMaximized: false }; } @@ -62,24 +101,10 @@ function saveWindowState(win: BrowserWindow): void { try { writeFileSync(getWindowStatePath(), JSON.stringify(state)); } catch { - // Ignore write errors + // Best effort only. } } -// Store last non-maximized bounds separately (avoids `any` cast on BrowserWindow) -let lastNormalBounds: Electron.Rectangle | null = null; - -// -------------------------------------------------- -// State -// -------------------------------------------------- -let mainWindow: BrowserWindow | null = null; -let serverShutdown: (() => Promise) | null = null; -let isQuitting = false; -let serverPort = 3721; - -// -------------------------------------------------- -// Port detection: try preferred port, fall back to random -// -------------------------------------------------- function findFreePort(preferred: number): Promise { return new Promise((resolve, reject) => { const srv = net.createServer(); @@ -87,46 +112,67 @@ function findFreePort(preferred: number): Promise { srv.close(() => resolve(preferred)); }); srv.on("error", () => { - // Preferred port occupied, use random - const srv2 = net.createServer(); - srv2.listen(0, "127.0.0.1", () => { - const port = (srv2.address() as net.AddressInfo).port; - srv2.close(() => resolve(port)); + const fallback = net.createServer(); + fallback.listen(0, "127.0.0.1", () => { + const port = (fallback.address() as net.AddressInfo).port; + fallback.close(() => resolve(port)); }); - srv2.on("error", (err) => { + fallback.on("error", (err) => { reject(new Error(`Cannot find a free port: ${err.message}`)); }); }); }); } -// -------------------------------------------------- -// Create main window -// -------------------------------------------------- -function createWindow(): BrowserWindow { - const state = loadWindowState(); +function getDataDir(): string { + if (process.env.DATA_DIR) { + return path.isAbsolute(process.env.DATA_DIR) + ? process.env.DATA_DIR + : path.resolve(app.getAppPath(), process.env.DATA_DIR); + } + return path.join(homedir(), ".chatcrystal", "data"); +} - // S-1: Validate saved position against current screen bounds - // If window would be off-screen (e.g., external monitor disconnected), reset position - if (state.x !== undefined && state.y !== undefined) { - const displays = screen.getAllDisplays(); - const visible = displays.some((d) => { - const b = d.bounds; - return ( - state.x! >= b.x - 50 && - state.x! < b.x + b.width && - state.y! >= b.y - 50 && - state.y! < b.y + b.height - ); - }); - if (!visible) { - state.x = undefined; - state.y = undefined; - } +function setRuntimeEnvironment(): void { + const dataDir = getDataDir(); + mkdirSync(dataDir, { recursive: true }); + process.env.ELECTRON = "true"; + process.env.DATA_DIR = dataDir; + if (app.isPackaged) { + process.env.ELECTRON_PACKAGED = "true"; } +} + +function validateSavedPosition(state: WindowState): void { + if (state.x === undefined || state.y === undefined) return; + + const displays = screen.getAllDisplays(); + const visible = displays.some((display) => { + const bounds = display.bounds; + return ( + state.x! >= bounds.x - 50 && + state.x! < bounds.x + bounds.width && + state.y! >= bounds.y - 50 && + state.y! < bounds.y + bounds.height + ); + }); + + if (!visible) { + state.x = undefined; + state.y = undefined; + } +} + +function preloadForMode(mode: WindowMode): string { + if (mode === "cloud") return path.join(__dirname, "cloud-preload.js"); + if (mode === "onboarding") return path.join(__dirname, "onboarding-preload.js"); + return path.join(__dirname, "preload.js"); +} + +function createWindow(mode: WindowMode): BrowserWindow { + const state = loadWindowState(); + validateSavedPosition(state); - // I-3: icon path — __dirname is electron/dist/ in both dev and packaged - const iconPath = path.join(__dirname, "..", "icon.png"); const win = new BrowserWindow({ width: state.width, height: state.height, @@ -136,12 +182,12 @@ function createWindow(): BrowserWindow { minHeight: 600, show: false, title: "ChatCrystal", - icon: iconPath, + icon: path.join(__dirname, "..", "icon.png"), webPreferences: { - preload: path.join(__dirname, "preload.js"), + preload: preloadForMode(mode), contextIsolation: true, nodeIntegration: false, - sandbox: true, // I-6: explicit sandbox + sandbox: true, }, }); @@ -149,7 +195,6 @@ function createWindow(): BrowserWindow { win.maximize(); } - // Track restore bounds for maximized state win.on("resize", () => { if (!win.isMaximized()) { lastNormalBounds = win.getBounds(); @@ -160,17 +205,13 @@ function createWindow(): BrowserWindow { lastNormalBounds = win.getBounds(); } }); - - // Show when ready to avoid flash win.once("ready-to-show", () => { win.show(); }); - - // Close → save state + hide to tray (unless quitting) - win.on("close", (e) => { + win.on("close", (event) => { saveWindowState(win); if (!isQuitting) { - e.preventDefault(); + event.preventDefault(); win.hide(); } }); @@ -178,163 +219,536 @@ function createWindow(): BrowserWindow { return win; } -// -------------------------------------------------- -// Graceful shutdown -// -------------------------------------------------- -async function gracefulShutdown(): Promise { - console.log("[Electron] Shutting down..."); - if (serverShutdown) { - await serverShutdown(); - serverShutdown = null; +function replaceMainWindow(mode: WindowMode): BrowserWindow { + if (mainWindow && !mainWindow.isDestroyed()) { + saveWindowState(mainWindow); + mainWindow.destroy(); } - destroyTray(); + mainWindow = createWindow(mode); + return mainWindow; } -// -------------------------------------------------- -// App lifecycle -// -------------------------------------------------- -app.on("second-instance", () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } -}); - -app.on("before-quit", (e) => { - if (!isQuitting) { - e.preventDefault(); - isQuitting = true; - // I-1: timeout prevents infinite hang if shutdown gets stuck - const timeout = setTimeout(() => { - console.error("[Electron] Shutdown timed out, forcing exit"); - app.exit(1); - }, 10000); - gracefulShutdown() - .catch((err) => console.error("[Electron] Shutdown error:", err)) - .finally(() => { - clearTimeout(timeout); - app.quit(); - }); +function isLocalHttpUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname); + } catch { + return false; } -}); +} -app.on("window-all-closed", () => { - // On Windows, don't quit when all windows closed (tray keeps running) - // This is handled by the close → hide logic above -}); +function configureLocalContentSecurityPolicy(): void { + if (process.env.VITE_DEV_URL) return; -// -------------------------------------------------- -// Data directory -// -------------------------------------------------- -function getDataDir(): string { - if (process.env.DATA_DIR) { - return path.isAbsolute(process.env.DATA_DIR) - ? process.env.DATA_DIR - : path.resolve(app.getAppPath(), process.env.DATA_DIR); - } + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + if (!isLocalHttpUrl(details.url)) { + callback({ responseHeaders: details.responseHeaders }); + return; + } - return path.join(homedir(), ".chatcrystal", "data"); + callback({ + responseHeaders: { + ...details.responseHeaders, + "Content-Security-Policy": [ + "default-src 'self';" + + " script-src 'self';" + + " style-src 'self' 'unsafe-inline';" + + " img-src 'self' data: blob:;" + + " font-src 'self' data:;" + + " connect-src 'self' http://localhost:* ws://localhost:*;" + + " object-src 'none';" + + " base-uri 'self'", + ], + }, + }); + }); } -// -------------------------------------------------- -// Server startup with retry -// -------------------------------------------------- -async function startServer(port: number): Promise<{ - shutdown: () => Promise; -}> { - const serverEntry = pathToFileURL( - path.join(app.getAppPath(), "server", "dist", "server", "src", "index.js"), - ).href; - // C-1: Function() constructor is used intentionally to bypass Electron's CJS - // bundler restrictions on dynamic import(). This is a known workaround for - // loading ESM server modules from a CJS main process. Replace with direct - // import() if the Electron main process is ever migrated to ESM. - const serverModule = (await Function( +async function importServerModule(relativeModulePath: string): Promise { + const modulePath = path.join( + app.getAppPath(), + "server", + "dist", + "server", + "src", + relativeModulePath, + ); + if (!existsSync(modulePath)) { + throw new Error( + `Missing compiled server module: ${modulePath}. Run npm run build -w server before packaging Electron.`, + ); + } + const moduleUrl = pathToFileURL(modulePath).href; + return (await Function( "specifier", "return import(specifier)", - )(serverEntry)) as { - createServer: (opts?: { port?: number; host?: string }) => Promise<{ - app: unknown; - port: number; - shutdown: () => Promise; - }>; - }; - return serverModule.createServer({ port, host: "127.0.0.1" }); + )(moduleUrl)) as T; } -// -------------------------------------------------- -// App lifecycle -// -------------------------------------------------- -app.whenReady().then(async () => { - try { - // 1. Determine data directory - const dataDir = getDataDir(); - - // 2. Ensure data directory exists - mkdirSync(dataDir, { recursive: true }); +async function startServer(port: number): Promise { + const serverModule = await importServerModule("index.js"); + return serverModule.createServer({ + port, + host: "127.0.0.1", + startWatcher: false, + }); +} - // 3. Set environment variables for the server - process.env.ELECTRON = "true"; - process.env.DATA_DIR = dataDir; - if (app.isPackaged) { - process.env.ELECTRON_PACKAGED = "true"; - } +function getDevCoreUrl(): string { + return process.env.CHATCRYSTAL_ELECTRON_DEV_CORE_URL ?? "http://localhost:3721"; +} - // 4. Set Content Security Policy (C-2) - // Restricts script execution to prevent XSS from rendered AI conversation content. - // Skipped in dev mode — Vite's HMR injects inline scripts incompatible with strict CSP. - if (!process.env.VITE_DEV_URL) { - session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - callback({ - responseHeaders: { - ...details.responseHeaders, - "Content-Security-Policy": [ - "default-src 'self';" + - " script-src 'self';" + - " style-src 'self' 'unsafe-inline';" + - " img-src 'self' data: blob:;" + - " font-src 'self' data:;" + - " connect-src 'self' http://localhost:* ws://localhost:*;" + - " object-src 'none';" + - " base-uri 'self'", - ], - }, - }); - }); - } +async function ensureLocalCoreStarted(): Promise<{ + appUrl: string; + apiBaseUrl: string; +}> { + const devUrl = process.env.VITE_DEV_URL; + if (devUrl) { + return { appUrl: devUrl, apiBaseUrl: getDevCoreUrl() }; + } - // 5. Find free port + if (!serverShutdown) { serverPort = await findFreePort(3721); if (serverPort !== 3721) { console.log(`[Electron] Port 3721 occupied, using port ${serverPort}`); } + const server = await startServer(serverPort); + serverShutdown = server.shutdown; + } + + const localUrl = `http://localhost:${serverPort}`; + return { appUrl: localUrl, apiBaseUrl: localUrl }; +} + +function normalizeCloudBaseUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("请输入云端地址"); + } + + const withProtocol = /^[a-z][a-z\d+\-.]*:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; + const url = new URL(withProtocol); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("云端地址必须使用 HTTP 或 HTTPS"); + } + url.hash = ""; + url.search = ""; + return url.toString().replace(/\/+$/, ""); +} - // 6. Start the Fastify server (skip in dev — server runs separately via tsx) - const devUrl = process.env.VITE_DEV_URL; - if (!devUrl) { - const server = await startServer(serverPort); - serverShutdown = server.shutdown; +function getApiError(payload: unknown, fallback: string): string { + if (payload && typeof payload === "object" && "error" in payload) { + const error = (payload as { error?: unknown }).error; + if (typeof error === "string" && error.trim()) return error; + } + return fallback; +} + +async function requestApi( + baseUrl: string, + apiPath: string, + options: RequestInit = {}, +): Promise { + const headers = new Headers(options.headers); + if (options.body !== undefined && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${baseUrl}${apiPath}`, { + ...options, + headers, + }); + const text = await response.text(); + let payload: ApiEnvelope | null = null; + if (text.trim()) { + try { + payload = JSON.parse(text) as ApiEnvelope; + } catch { + payload = null; } + } - // 7. Create window - mainWindow = createWindow(); + if (!response.ok) { + throw new Error( + getApiError(payload, `请求失败:HTTP ${response.status}`), + ); + } + if (!payload) { + throw new Error("服务器返回了无效响应"); + } + if (!payload.success) { + throw new Error(payload.error || "请求失败"); + } + return payload.data; +} - // 8. Load the app - const url = devUrl || `http://localhost:${serverPort}`; - await mainWindow.loadURL(url); +function withAuth(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} - // 9. Create tray - createTray(mainWindow, serverPort); +async function verifyCloudConnection(baseUrl: string, token: string): Promise { + const status = await requestApi<{ + cloudMode: boolean; + setupRequired: boolean; + authenticated: boolean; + }>(baseUrl, "/api/setup/status"); + + if (!status.cloudMode) { + throw new Error("该地址不是 ChatCrystal 云端核心"); + } + if (status.setupRequired) { + throw new Error("云端核心尚未完成初始设置"); + } + + const verify = await requestApi<{ authenticated: boolean }>( + baseUrl, + "/api/auth/verify", + { method: "POST", headers: withAuth(token) }, + ); + if (!verify.authenticated) { + throw new Error("Token 验证失败"); + } +} + +function readConfiguredCloudState(): ElectronOnboardingState { + const state = readElectronState(); + if (!state.cloudBaseUrl || !state.cloudToken) { + throw new Error("请先配置云端地址和 token"); + } + return state; +} + +function writeModeState( + mode: "local" | "cloud", + patch: Partial = {}, +): ElectronOnboardingState { + const current = readElectronState(); + const next: ElectronOnboardingState = { + ...current, + ...patch, + version: 1, + mode, + defaultMode: patch.defaultMode === undefined ? mode : patch.defaultMode, + }; + writeElectronState(next); + return next; +} + +async function saveCloudConnection(input: { + baseUrl: string; + token: string; +}): Promise<{ mode: "cloud"; cloudBaseUrl: string; httpsRecommended: boolean }> { + const token = input.token.trim(); + if (!token) { + throw new Error("请输入访问 token"); + } + const cloudBaseUrl = normalizeCloudBaseUrl(input.baseUrl); + await verifyCloudConnection(cloudBaseUrl, token); + writeModeState("cloud", { + cloudBaseUrl, + cloudToken: token, + importSkipped: false, + mcpSkipped: false, + summarizationBatchIds: [], + summarizationRequestId: null, + }); + return { + mode: "cloud", + cloudBaseUrl, + httpsRecommended: new URL(cloudBaseUrl).protocol === "http:", + }; +} + +async function startLocalMode(): Promise<{ + mode: "local"; + appUrl: string; + apiBaseUrl: string; +}> { + const local = await ensureLocalCoreStarted(); + writeModeState("local", { + importSkipped: false, + mcpSkipped: false, + summarizationBatchIds: [], + summarizationRequestId: null, + }); + return { mode: "local", ...local }; +} + +async function getModeApiBaseUrl(mode: "local" | "cloud"): Promise<{ + baseUrl: string; + token?: string; +}> { + if (mode === "local") { + const local = await ensureLocalCoreStarted(); + return { baseUrl: local.apiBaseUrl }; + } + + const state = readConfiguredCloudState(); + return { baseUrl: state.cloudBaseUrl!, token: state.cloudToken! }; +} + +async function callModeApi( + mode: "local" | "cloud", + apiPath: string, + options: RequestInit = {}, +): Promise { + const target = await getModeApiBaseUrl(mode); + const headers = new Headers(options.headers); + if (target.token) { + headers.set("Authorization", `Bearer ${target.token}`); + } + return requestApi(target.baseUrl, apiPath, { ...options, headers }); +} + +async function importLocalHistory(): Promise { + return callModeApi("local", "/api/import/scan", { method: "POST" }); +} + +async function uploadLocalHistory(): Promise { + const state = readConfiguredCloudState(); + const remoteImport = await importServerModule( + path.join("services", "remoteImport.js"), + ); + + return remoteImport.runRemoteImport({ + ingestConversations: (request) => + requestApi(state.cloudBaseUrl!, "/api/import/ingest", { + method: "POST", + headers: withAuth(state.cloudToken!), + body: JSON.stringify(request), + }), + }); +} + +async function testModel(mode: "local" | "cloud"): Promise { + return callModeApi(mode, "/api/config/test", { method: "POST" }); +} + +async function summarizeBatch(input: { + mode: "local" | "cloud"; + conversationIds: string[]; +}): Promise { + const result = await callModeApi(input.mode, "/api/summarize/batch-ids", { + method: "POST", + body: JSON.stringify({ conversationIds: input.conversationIds }), + }); + writeModeState(input.mode, { + summarizationBatchIds: input.conversationIds, + summarizationRequestId: new Date().toISOString(), + }); + return result; +} + +async function getMcpSnippet(mode: "local" | "cloud"): Promise { + if (mode === "local") { + const local = await ensureLocalCoreStarted(); + return buildMcpSnippet({ mode: "local", baseUrl: local.apiBaseUrl }); + } + + const state = readConfiguredCloudState(); + return buildMcpSnippet({ + mode: "cloud", + baseUrl: state.cloudBaseUrl!, + token: state.cloudToken!, + }); +} + +function lockNavigationToOrigin(win: BrowserWindow, origin: string): void { + const isAllowed = (url: string) => new URL(url).origin === origin; + win.webContents.on("will-navigate", (event, url) => { + if (!isAllowed(url)) { + event.preventDefault(); + } + }); + win.webContents.on("will-redirect", (event, url) => { + if (!isAllowed(url)) { + event.preventDefault(); + } + }); + win.webContents.setWindowOpenHandler(({ url }) => { + if (isAllowed(url)) { + return { action: "allow" }; + } + return { action: "deny" }; + }); +} + +function lockNavigationToUrl(win: BrowserWindow, allowedUrl: string): void { + const isAllowed = (url: string) => url === allowedUrl; + win.webContents.on("will-navigate", (event, url) => { + if (!isAllowed(url)) { + event.preventDefault(); + } + }); + win.webContents.on("will-redirect", (event, url) => { + if (!isAllowed(url)) { + event.preventDefault(); + } + }); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); +} + +function assertWindowOrigin(win: BrowserWindow, expectedOrigin: string): void { + const actualUrl = win.webContents.getURL(); + const actualOrigin = new URL(actualUrl).origin; + if (actualOrigin !== expectedOrigin) { + throw new Error( + `云端地址跳转到了不同来源:${actualOrigin},请确认云端 URL 是否正确`, + ); + } +} + +async function injectCloudToken(win: BrowserWindow, token: string): Promise { + await win.webContents.executeJavaScript( + `window.localStorage.setItem(${JSON.stringify(API_TOKEN_LOCAL_STORAGE_KEY)}, ${JSON.stringify(token)}); +window.dispatchEvent(new Event(${JSON.stringify(AUTH_CHANGED_EVENT)}));`, + true, + ); +} + +async function openLocalApp(): Promise<{ mode: "local"; appUrl: string }> { + const local = await ensureLocalCoreStarted(); + const win = replaceMainWindow("local"); + await win.loadURL(local.appUrl); + createTray({ win, mode: "local", localBaseUrl: local.appUrl }); + writeModeState("local"); + return { mode: "local", appUrl: local.appUrl }; +} + +async function openCloudApp(): Promise<{ mode: "cloud"; cloudBaseUrl: string }> { + const state = readConfiguredCloudState(); + const cloudBaseUrl = state.cloudBaseUrl!; + const win = replaceMainWindow("cloud"); + const expectedOrigin = new URL(cloudBaseUrl).origin; + lockNavigationToOrigin(win, expectedOrigin); + await win.loadURL(cloudBaseUrl); + assertWindowOrigin(win, expectedOrigin); + await injectCloudToken(win, state.cloudToken!); + createTray({ win, mode: "cloud", cloudBaseUrl }); + writeModeState("cloud"); + return { mode: "cloud", cloudBaseUrl }; +} + +async function openOnboarding(initialError?: string): Promise { + const win = replaceMainWindow("onboarding"); + currentOnboardingUrl = getOnboardingDataUrl(initialError); + lockNavigationToUrl(win, currentOnboardingUrl); + await win.loadURL(currentOnboardingUrl); + createTray({ win, mode: "onboarding" }); +} + +async function openApp(mode: "local" | "cloud"): Promise { + if (mode === "cloud") return openCloudApp(); + return openLocalApp(); +} + +async function useTemporaryLocal(): Promise { + const local = await ensureLocalCoreStarted(); + writeModeState("local", { defaultMode: null }); + return local; +} + +function getRedactedState(): ElectronOnboardingState { + const state = readElectronState(); + return { ...state, cloudToken: redactToken(state.cloudToken) }; +} + +function registerIpcHandlers(): void { + registerOnboardingIpc({ + getOnboardingOrigin: () => ONBOARDING_ORIGIN, + getOnboardingUrl: () => currentOnboardingUrl, + getState: getRedactedState, + saveCloudConnection, + startLocal: startLocalMode, + importLocalHistory, + uploadLocalHistory, + testModel, + summarizeBatch, + getMcpSnippet, + openApp, + useTemporaryLocal, + }); +} + +async function gracefulShutdown(): Promise { + console.log("[Electron] Shutting down..."); + if (serverShutdown) { + await serverShutdown(); + serverShutdown = null; + } + destroyTray(); +} + +app.on("second-instance", () => { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); +}); + +app.on("before-quit", (event) => { + if (isQuitting) return; + + event.preventDefault(); + isQuitting = true; + const timeout = setTimeout(() => { + console.error("[Electron] Shutdown timed out, forcing exit"); + app.exit(1); + }, 10000); + gracefulShutdown() + .catch((err) => console.error("[Electron] Shutdown error:", err)) + .finally(() => { + clearTimeout(timeout); + app.quit(); + }); +}); + +app.on("window-all-closed", () => { + // Windows tray keeps the app alive. +}); + +app.whenReady().then(async () => { + try { + setRuntimeEnvironment(); + configureLocalContentSecurityPolicy(); + registerIpcHandlers(); + + const state = readElectronState(); + if (state.defaultMode === "cloud" && state.cloudBaseUrl && state.cloudToken) { + try { + await openCloudApp(); + console.log(`[Electron] ChatCrystal cloud mode ready at ${state.cloudBaseUrl}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[Electron] Cloud mode startup failed:", err); + await openOnboarding(`云端连接失败:${message}`); + } + return; + } + + if (state.defaultMode === "local") { + try { + const local = await openLocalApp(); + console.log(`[Electron] ChatCrystal local mode ready at ${local.appUrl}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error("[Electron] Local mode startup failed:", err); + await openOnboarding(`本地核心启动失败:${message}`); + } + return; + } - console.log(`[Electron] ChatCrystal ready at ${url}`); + await openOnboarding(); + console.log("[Electron] ChatCrystal onboarding ready"); } catch (err) { console.error("[Electron] Failed to start:", err); const message = err instanceof Error ? err.message : String(err); dialog.showErrorBox( "ChatCrystal failed to start", - `An error occurred during startup:\n\n${message}\n\nPlease check if the port is in use or data directory permissions.`, + `An error occurred during startup:\n\n${message}\n\nPlease check the cloud URL, token, port availability, or data directory permissions.`, ); app.quit(); } diff --git a/electron/mcp-snippets.ts b/electron/mcp-snippets.ts new file mode 100644 index 0000000..25e22c3 --- /dev/null +++ b/electron/mcp-snippets.ts @@ -0,0 +1,45 @@ +export type McpSnippetInput = + | { mode: "local"; baseUrl: string } + | { mode: "cloud"; baseUrl: string; token: string }; + +export type McpServerEntry = { + command: "crystal"; + args: ["mcp"]; + env: Record; +}; + +export type McpSnippet = { + mcpServers: { + chatcrystal: McpServerEntry; + }; +}; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +export function isNonLocalHttpUrl(baseUrl: string): boolean { + const url = new URL(baseUrl); + return url.protocol === "http:" && !LOCAL_HOSTS.has(url.hostname); +} + +export function buildMcpSnippet(input: McpSnippetInput): McpSnippet { + const env: Record = { + CHATCRYSTAL_BASE_URL: input.baseUrl, + }; + + if (input.mode === "cloud") { + env.CHATCRYSTAL_API_TOKEN = input.token; + if (isNonLocalHttpUrl(input.baseUrl)) { + env.CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP = "true"; + } + } + + return { + mcpServers: { + chatcrystal: { + command: "crystal", + args: ["mcp"], + env, + }, + }, + }; +} diff --git a/electron/onboarding-page.ts b/electron/onboarding-page.ts new file mode 100644 index 0000000..6b0e2ec --- /dev/null +++ b/electron/onboarding-page.ts @@ -0,0 +1,137 @@ +export function getOnboardingDataUrl(initialError = ""): string { + const html = ` + + + + + ChatCrystal Onboarding + + + +

正在唤醒您的超级大脑

+ + +`; + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} diff --git a/electron/onboarding-preload.ts b/electron/onboarding-preload.ts new file mode 100644 index 0000000..dd2ed00 --- /dev/null +++ b/electron/onboarding-preload.ts @@ -0,0 +1,17 @@ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("chatcrystalOnboarding", { + getState: () => ipcRenderer.invoke("onboarding:get-state"), + saveCloudConnection: (input: { baseUrl: string; token: string }) => + ipcRenderer.invoke("onboarding:save-cloud-connection", input), + startLocal: () => ipcRenderer.invoke("onboarding:start-local"), + importLocalHistory: () => ipcRenderer.invoke("onboarding:import-local-history"), + uploadLocalHistory: () => ipcRenderer.invoke("onboarding:upload-local-history"), + testModel: (mode: "local" | "cloud") => ipcRenderer.invoke("onboarding:test-model", mode), + summarizeBatch: (input: { mode: "local" | "cloud"; conversationIds: string[] }) => + ipcRenderer.invoke("onboarding:summarize-batch", input), + getMcpSnippet: (mode: "local" | "cloud") => + ipcRenderer.invoke("onboarding:get-mcp-snippet", mode), + openApp: (mode: "local" | "cloud") => ipcRenderer.invoke("onboarding:open-app", mode), + useTemporaryLocal: () => ipcRenderer.invoke("onboarding:use-temporary-local"), +}); diff --git a/electron/state.ts b/electron/state.ts new file mode 100644 index 0000000..b386187 --- /dev/null +++ b/electron/state.ts @@ -0,0 +1,84 @@ +import { + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; + +export type ElectronMode = "unset" | "local" | "cloud"; + +export type ElectronOnboardingState = { + version: 1; + mode: ElectronMode; + defaultMode: Exclude | null; + cloudBaseUrl: string | null; + cloudToken: string | null; + importSkipped: boolean; + mcpSkipped: boolean; + summarizationBatchIds: string[]; + summarizationRequestId: string | null; + updatedAt: string; +}; + +export const DEFAULT_ELECTRON_STATE: ElectronOnboardingState = { + version: 1, + mode: "unset", + defaultMode: null, + cloudBaseUrl: null, + cloudToken: null, + importSkipped: false, + mcpSkipped: false, + summarizationBatchIds: [], + summarizationRequestId: null, + updatedAt: new Date(0).toISOString(), +}; + +function getDefaultUserDataDir(): string { + const electron = require("electron") as typeof import("electron") | string; + if (typeof electron === "string" || !electron.app) { + throw new Error("Electron app is unavailable outside the Electron main process"); + } + return electron.app.getPath("userData"); +} + +export function getElectronStatePath(userDataDir = getDefaultUserDataDir()): string { + return path.join(userDataDir, "onboarding-state.json"); +} + +export function redactToken(value: string | null): string | null { + if (!value) return null; + if (value.length <= 8) return "****"; + return `${value.slice(0, 4)}****${value.slice(-4)}`; +} + +export function readElectronState(userDataDir?: string): ElectronOnboardingState { + try { + const parsed = JSON.parse( + readFileSync(getElectronStatePath(userDataDir), "utf-8"), + ) as Partial; + if (parsed.version !== 1) return DEFAULT_ELECTRON_STATE; + return { + ...DEFAULT_ELECTRON_STATE, + ...parsed, + updatedAt: parsed.updatedAt ?? new Date().toISOString(), + }; + } catch { + return DEFAULT_ELECTRON_STATE; + } +} + +export function writeElectronState( + next: ElectronOnboardingState, + userDataDir?: string, +): void { + const filePath = getElectronStatePath(userDataDir); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + JSON.stringify({ ...next, updatedAt: new Date().toISOString() }, null, 2), + { + encoding: "utf-8", + mode: 0o600, + }, + ); +} diff --git a/electron/test/mcp-snippets.test.ts b/electron/test/mcp-snippets.test.ts new file mode 100644 index 0000000..c7a05bb --- /dev/null +++ b/electron/test/mcp-snippets.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildMcpSnippet, isNonLocalHttpUrl } from "../mcp-snippets.js"; + +test("local MCP snippet points crystal mcp at the active local core", () => { + assert.deepEqual(buildMcpSnippet({ mode: "local", baseUrl: "http://localhost:3721" }), { + mcpServers: { + chatcrystal: { + command: "crystal", + args: ["mcp"], + env: { + CHATCRYSTAL_BASE_URL: "http://localhost:3721", + }, + }, + }, + }); +}); + +test("cloud MCP snippet includes base URL and API token", () => { + assert.deepEqual(buildMcpSnippet({ + mode: "cloud", + baseUrl: "https://crystal.example.com", + token: "plain-token", + }), { + mcpServers: { + chatcrystal: { + command: "crystal", + args: ["mcp"], + env: { + CHATCRYSTAL_BASE_URL: "https://crystal.example.com", + CHATCRYSTAL_API_TOKEN: "plain-token", + }, + }, + }, + }); +}); + +test("non-local HTTP cloud MCP snippet includes explicit insecure transport allowance", () => { + const snippet = buildMcpSnippet({ + mode: "cloud", + baseUrl: "http://crystal.example.com", + token: "plain-token", + }); + + assert.equal(isNonLocalHttpUrl("http://crystal.example.com"), true); + assert.deepEqual(snippet.mcpServers.chatcrystal.env, { + CHATCRYSTAL_BASE_URL: "http://crystal.example.com", + CHATCRYSTAL_API_TOKEN: "plain-token", + CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP: "true", + }); +}); + +test("localhost HTTP is not treated as non-local insecure MCP transport", () => { + assert.equal(isNonLocalHttpUrl("http://localhost:3721"), false); + assert.equal(isNonLocalHttpUrl("http://127.0.0.1:3721"), false); +}); diff --git a/electron/test/state.test.ts b/electron/test/state.test.ts new file mode 100644 index 0000000..a8adc9b --- /dev/null +++ b/electron/test/state.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + DEFAULT_ELECTRON_STATE, + getElectronStatePath, + readElectronState, + redactToken, + writeElectronState, +} from "../state.js"; + +test("electron onboarding state persists cloud token as plaintext JSON", () => { + const userDataDir = mkdtempSync(path.join(tmpdir(), "chatcrystal-electron-state-test-")); + try { + writeElectronState({ + ...DEFAULT_ELECTRON_STATE, + mode: "cloud", + defaultMode: "cloud", + cloudBaseUrl: "https://crystal.example.com", + cloudToken: "plain-token", + importSkipped: true, + }, userDataDir); + + const filePath = getElectronStatePath(userDataDir); + const raw = readFileSync(filePath, "utf-8"); + assert.match(raw, /"cloudToken": "plain-token"/); + + const state = readElectronState(userDataDir); + assert.equal(state.mode, "cloud"); + assert.equal(state.defaultMode, "cloud"); + assert.equal(state.cloudBaseUrl, "https://crystal.example.com"); + assert.equal(state.cloudToken, "plain-token"); + assert.equal(state.importSkipped, true); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } +}); + +test("electron onboarding state falls back to defaults when JSON is corrupted", () => { + const userDataDir = mkdtempSync(path.join(tmpdir(), "chatcrystal-electron-state-test-")); + try { + writeFileSync(getElectronStatePath(userDataDir), "{not json", "utf-8"); + assert.deepEqual(readElectronState(userDataDir), DEFAULT_ELECTRON_STATE); + } finally { + rmSync(userDataDir, { recursive: true, force: true }); + } +}); + +test("redactToken keeps token readable enough for support without exposing the secret", () => { + assert.equal(redactToken(null), null); + assert.equal(redactToken("short"), "****"); + assert.equal(redactToken("token-1234567890"), "toke****7890"); +}); diff --git a/electron/tray.ts b/electron/tray.ts index 533e190..4e28b15 100644 --- a/electron/tray.ts +++ b/electron/tray.ts @@ -3,7 +3,17 @@ import path from "node:path"; let tray: Tray | null = null; -export function createTray(win: BrowserWindow, port: number): Tray { +export type TrayOptions = + | { win: BrowserWindow; mode: "onboarding" } + | { win: BrowserWindow; mode: "local"; localBaseUrl: string } + | { win: BrowserWindow; mode: "cloud"; cloudBaseUrl: string }; + +export function createTray(options: TrayOptions): Tray { + if (tray) { + tray.destroy(); + tray = null; + } + // Use icon from electron directory const iconPath = path.join(__dirname, "..", "icon.png"); const icon = nativeImage @@ -13,7 +23,14 @@ export function createTray(win: BrowserWindow, port: number): Tray { tray = new Tray(icon); tray.setToolTip("ChatCrystal"); - const contextMenu = Menu.buildFromTemplate([ + const openTarget = + options.mode === "cloud" + ? options.cloudBaseUrl + : options.mode === "local" + ? options.localBaseUrl + : null; + + const menuItems: Electron.MenuItemConstructorOptions[] = [ { label: "ChatCrystal", enabled: false, @@ -22,25 +39,32 @@ export function createTray(win: BrowserWindow, port: number): Tray { { label: "Open Window", click: () => { - win.show(); - win.focus(); + options.win.show(); + options.win.focus(); }, }, - { - label: "Search Knowledge", - click: () => { - win.show(); - win.focus(); - const baseUrl = `http://localhost:${port}`; - win.loadURL(`${baseUrl}/search`); + ]; + + if (openTarget) { + menuItems.push( + { + label: "Search Knowledge", + click: () => { + options.win.show(); + options.win.focus(); + options.win.loadURL(`${openTarget}/search`); + }, }, - }, - { - label: "Open in Browser", - click: () => { - shell.openExternal(`http://localhost:${port}`); + { + label: "Open in Browser", + click: () => { + shell.openExternal(openTarget); + }, }, - }, + ); + } + + menuItems.push( { type: "separator" }, { label: "Quit", @@ -49,14 +73,16 @@ export function createTray(win: BrowserWindow, port: number): Tray { app.quit(); }, }, - ]); + ); + + const contextMenu = Menu.buildFromTemplate(menuItems); tray.setContextMenu(contextMenu); // Double-click to show window tray.on("double-click", () => { - win.show(); - win.focus(); + options.win.show(); + options.win.focus(); }); return tray; diff --git a/server/src/cli/client.ts b/server/src/cli/client.ts index 070ec16..fec31b9 100644 --- a/server/src/cli/client.ts +++ b/server/src/cli/client.ts @@ -304,6 +304,7 @@ export class CrystalClient { await this.ensureExpectedInstance('/api/import/scan/stream'); const res = await fetch(`${this.baseUrl}/api/import/scan/stream`, { + method: 'POST', headers: { Accept: 'text/event-stream', ...this.authHeaders() }, }); diff --git a/server/src/index.ts b/server/src/index.ts index 2394a93..bb5b6b6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,6 +21,7 @@ import { healthRoutes } from './routes/health.js'; import { authRoutes, registerCloudAuthHook } from './routes/setup.js'; import { getOrCreateSetupCode, setupRequired } from './services/auth.js'; import { isCloudMode } from './runtime/cloud.js'; +import { shouldStartWatcher } from './runtime/watcherPolicy.js'; // Initialize parser adapters (registers built-in adapters) import './parser/index.js'; @@ -39,6 +40,7 @@ export interface ServerInstance { export async function createServer(options?: { port?: number; host?: string; + startWatcher?: boolean; }): Promise { const app = Fastify({ logger: true, bodyLimit: 25 * 1024 * 1024 }); @@ -98,8 +100,14 @@ export async function createServer(options?: { console.log(`[LLM] Provider: ${appConfig.llm.provider} / ${appConfig.llm.model}`); console.log(`[Embedding] Provider: ${appConfig.embedding.provider} / ${appConfig.embedding.model}`); - // Start file watcher only for local mode; cloud imports are pushed from clients. - const watcher = isCloudMode() ? null : startWatcher(); + // Start file watcher only when the runtime mode explicitly allows local scans. + const cloudMode = isCloudMode(); + const watcher = shouldStartWatcher({ + cloudMode, + startWatcher: options?.startWatcher, + }) + ? startWatcher() + : null; // Start server const port = options?.port ?? appConfig.port; diff --git a/server/src/routes/import-cloud.test.ts b/server/src/routes/import-cloud.test.ts index e2fd064..b0b1d2f 100644 --- a/server/src/routes/import-cloud.test.ts +++ b/server/src/routes/import-cloud.test.ts @@ -26,7 +26,7 @@ test('cloud mode rejects import scan stream as local-only', async () => { const app = Fastify(); await app.register(importRoutes); - const response = await app.inject({ method: 'GET', url: '/api/import/scan/stream' }); + const response = await app.inject({ method: 'POST', url: '/api/import/scan/stream' }); assert.equal(response.statusCode, 400); assert.match(response.body, /local-only/i); diff --git a/server/src/routes/import-origin.test.ts b/server/src/routes/import-origin.test.ts new file mode 100644 index 0000000..5ee37f4 --- /dev/null +++ b/server/src/routes/import-origin.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isLocalImportRequestOriginAllowed } from './import.js'; + +test('local import side effects allow same-machine browser origins', () => { + assert.equal(isLocalImportRequestOriginAllowed('http://localhost:13721', undefined), true); + assert.equal(isLocalImportRequestOriginAllowed('http://127.0.0.1:13721', undefined), true); + assert.equal(isLocalImportRequestOriginAllowed(undefined, 'http://localhost:3721/import'), true); +}); + +test('local import side effects reject non-local browser origins', () => { + assert.equal(isLocalImportRequestOriginAllowed('https://example.com', undefined), false); + assert.equal(isLocalImportRequestOriginAllowed(undefined, 'https://example.com/page'), false); +}); + +test('local import side effects allow CLI and Electron requests without browser origin headers', () => { + assert.equal(isLocalImportRequestOriginAllowed(undefined, undefined), true); +}); diff --git a/server/src/routes/import.ts b/server/src/routes/import.ts index 8fc5f7b..953efb8 100644 --- a/server/src/routes/import.ts +++ b/server/src/routes/import.ts @@ -1,9 +1,46 @@ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { RemoteImportRequest } from '@chatcrystal/shared'; import { isCloudMode } from '../runtime/cloud.js'; import { ingestRemoteImport } from '../services/ingest.js'; import { importAll } from '../services/import.js'; +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +type HeaderValue = string | string[] | undefined; + +function firstHeaderValue(value: HeaderValue): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +function isLocalOrigin(value: string): boolean { + try { + const url = new URL(value); + return LOCAL_HOSTS.has(url.hostname); + } catch { + return false; + } +} + +export function isLocalImportRequestOriginAllowed(origin: HeaderValue, referer: HeaderValue): boolean { + const originValue = firstHeaderValue(origin); + if (originValue) return isLocalOrigin(originValue); + + const refererValue = firstHeaderValue(referer); + if (refererValue) return isLocalOrigin(refererValue); + + return true; +} + +function rejectNonLocalImportOrigin(req: FastifyRequest, reply: FastifyReply): boolean { + if (!isLocalImportRequestOriginAllowed(req.headers.origin, req.headers.referer)) { + reply.status(403).send({ success: false, error: 'Local import must be started from ChatCrystal.' }); + return true; + } + + return false; +} + export async function importRoutes(app: FastifyInstance) { app.post('/api/import/ingest', async (req, reply) => { if (!isCloudMode()) { @@ -31,7 +68,7 @@ export async function importRoutes(app: FastifyInstance) { }); // Trigger a full scan and import (JSON response, no progress) - app.post('/api/import/scan', async (_req, reply) => { + app.post('/api/import/scan', async (req, reply) => { if (isCloudMode()) { reply.status(400); return { @@ -39,6 +76,7 @@ export async function importRoutes(app: FastifyInstance) { error: 'Server-side import scan is local-only and is disabled in cloud mode. Run crystal import from the device that has the source histories.', }; } + if (rejectNonLocalImportOrigin(req, reply)) return; try { const result = await importAll(); @@ -51,7 +89,7 @@ export async function importRoutes(app: FastifyInstance) { }); // SSE endpoint for import with real-time progress - app.get('/api/import/scan/stream', async (_req, reply) => { + app.post('/api/import/scan/stream', async (req, reply) => { if (isCloudMode()) { reply.status(400); return { @@ -59,6 +97,7 @@ export async function importRoutes(app: FastifyInstance) { error: 'Server-side import scan stream is local-only and is disabled in cloud mode. Run crystal import from the device that has the source histories.', }; } + if (rejectNonLocalImportOrigin(req, reply)) return; reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', diff --git a/server/src/routes/notes-summarize-batch.test.ts b/server/src/routes/notes-summarize-batch.test.ts new file mode 100644 index 0000000..cf99ff3 --- /dev/null +++ b/server/src/routes/notes-summarize-batch.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import Fastify from 'fastify'; + +const dataDir = mkdtempSync(join(tmpdir(), 'chatcrystal-summarize-route-test-')); +process.env.DATA_DIR = dataDir; + +const dbService = await import('../db/index.js'); +const queue = await import('../queue/index.js'); +const { noteRoutes } = await import('./notes.js'); + +function resetDatabase(db: Awaited>) { + db.exec(` + PRAGMA foreign_keys = ON; + DELETE FROM experience_reviews; + DELETE FROM note_tags; + DELETE FROM embeddings; + DELETE FROM note_relations; + DELETE FROM notes; + DELETE FROM messages; + DELETE FROM conversations; + DELETE FROM import_log; + DELETE FROM vector_cleanup_tasks; + `); +} + +function insertConversation( + db: Awaited>, + id: string, + status: string, +) { + db.run( + `INSERT INTO conversations ( + id, source, project_name, project_dir, message_count, + first_message_at, last_message_at, file_path, file_size, file_mtime, status + ) VALUES (?, 'codex', 'p', 'p', 2, '2026-05-23', '2026-05-23', ?, 1, 'm', ?)`, + [id, `C:/fixtures/${id}.jsonl`, status], + ); +} + +test('summarize by ids reports summarized and unknown conversations without queueing them', async () => { + const db = await dbService.initDatabase(); + resetDatabase(db); + insertConversation(db, 'old-id', 'summarized'); + + const app = Fastify(); + await app.register(noteRoutes); + + const res = await app.inject({ + method: 'POST', + url: '/api/summarize/batch-ids', + payload: { conversationIds: ['old-id', 'old-id', 'missing-id'] }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.success, true); + assert.equal(body.data.queued, 0); + assert.deepEqual(body.data.skipped, ['old-id']); + assert.deepEqual(body.data.unknown, ['missing-id']); + + await app.close(); +}); + +test('summarize by ids queues only requested imported conversations', async () => { + const db = await dbService.initDatabase(); + resetDatabase(db); + insertConversation(db, 'new-id', 'imported'); + insertConversation(db, 'old-id', 'summarized'); + + const app = Fastify(); + await app.register(noteRoutes); + queue.summarizeQueue.pause(); + + try { + const res = await app.inject({ + method: 'POST', + url: '/api/summarize/batch-ids', + payload: { conversationIds: ['new-id', 'old-id', 'missing-id'] }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.success, true); + assert.equal(body.data.queued, 1); + assert.deepEqual(body.data.skipped, ['old-id']); + assert.deepEqual(body.data.unknown, ['missing-id']); + } finally { + queue.cancelQueue(); + queue.summarizeQueue.start(); + } + + await app.close(); +}); + +test('summarize by ids accepts empty bodies as empty requests', async () => { + const app = Fastify(); + await app.register(noteRoutes); + + const res = await app.inject({ + method: 'POST', + url: '/api/summarize/batch-ids', + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.success, true); + assert.equal(body.data.queued, 0); + assert.deepEqual(body.data.skipped, []); + assert.deepEqual(body.data.unknown, []); + + await app.close(); +}); + +test('summarize status by ids returns conversation status or unknown', async () => { + const db = await dbService.initDatabase(); + resetDatabase(db); + insertConversation(db, 'new-id', 'imported'); + insertConversation(db, 'old-id', 'summarized'); + + const app = Fastify(); + await app.register(noteRoutes); + + const res = await app.inject({ + method: 'POST', + url: '/api/summarize/status-ids', + payload: { conversationIds: ['new-id', 'old-id', 'missing-id'] }, + }); + + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.success, true); + assert.deepEqual(body.data.items, [ + { id: 'new-id', status: 'imported' }, + { id: 'old-id', status: 'summarized' }, + { id: 'missing-id', status: 'unknown' }, + ]); + + await app.close(); + dbService.closeDatabase(); + rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/server/src/routes/notes.ts b/server/src/routes/notes.ts index 4aadf9c..a832933 100644 --- a/server/src/routes/notes.ts +++ b/server/src/routes/notes.ts @@ -76,6 +76,64 @@ export async function noteRoutes(app: FastifyInstance) { }; }); + app.post('/api/summarize/batch-ids', async (req) => { + const body = (req.body ?? {}) as { conversationIds?: unknown }; + const { conversationIds } = body; + const requested = [...new Set( + Array.isArray(conversationIds) + ? conversationIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + : [], + )]; + const db = getDatabase(); + const skipped: string[] = []; + const unknown: string[] = []; + let queued = 0; + + for (const id of requested) { + const row = db.exec( + 'SELECT project_name, slug, status FROM conversations WHERE id = ?', + [id], + )[0]?.values[0]; + if (!row) { + unknown.push(id); + continue; + } + + const [projectName, slug, status] = row as [string, string | null, string]; + if (status !== 'imported' || taskTracker.isTaskActive(id)) { + skipped.push(id); + continue; + } + + const title = `${projectName} / ${slug || id.slice(0, 8)}`; + enqueueWithRetry(id, title, () => triggerSummarize(id)).catch((err) => { + console.error(`[Summarize] Error for ${id}:`, err instanceof Error ? err.message : err); + }); + queued++; + } + + return { + success: true, + data: { queued, skipped, unknown, queue: getQueueStatus() }, + }; + }); + + app.post('/api/summarize/status-ids', async (req) => { + const body = (req.body ?? {}) as { conversationIds?: unknown }; + const { conversationIds } = body; + const requested = [...new Set( + Array.isArray(conversationIds) + ? conversationIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0) + : [], + )]; + const db = getDatabase(); + const items = requested.map((id) => { + const row = db.exec('SELECT status FROM conversations WHERE id = ?', [id])[0]?.values[0]; + return { id, status: row ? String(row[0]) : 'unknown' }; + }); + return { success: true, data: { items, queue: getQueueStatus() } }; + }); + // Reset error conversations back to 'imported' for retry app.post('/api/summarize/reset-errors', async () => { const db = getDatabase(); diff --git a/server/src/runtime/watcherPolicy.test.ts b/server/src/runtime/watcherPolicy.test.ts new file mode 100644 index 0000000..2f34533 --- /dev/null +++ b/server/src/runtime/watcherPolicy.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { shouldStartWatcher } from './watcherPolicy.js'; + +test('standalone local server starts watcher by default', () => { + assert.equal(shouldStartWatcher({ cloudMode: false }), true); +}); + +test('cloud server never starts watcher by default', () => { + assert.equal(shouldStartWatcher({ cloudMode: true }), false); +}); + +test('electron onboarding can explicitly disable watcher in local mode', () => { + assert.equal(shouldStartWatcher({ cloudMode: false, startWatcher: false }), false); +}); + +test('explicit true still cannot start watcher in cloud mode', () => { + assert.equal(shouldStartWatcher({ cloudMode: true, startWatcher: true }), false); +}); diff --git a/server/src/runtime/watcherPolicy.ts b/server/src/runtime/watcherPolicy.ts new file mode 100644 index 0000000..fed382e --- /dev/null +++ b/server/src/runtime/watcherPolicy.ts @@ -0,0 +1,9 @@ +export type WatcherPolicyInput = { + cloudMode: boolean; + startWatcher?: boolean; +}; + +export function shouldStartWatcher(input: WatcherPolicyInput): boolean { + if (input.cloudMode) return false; + return input.startWatcher ?? true; +} diff --git a/server/src/services/import.test.ts b/server/src/services/import.test.ts index 0ce709e..544b97a 100644 --- a/server/src/services/import.test.ts +++ b/server/src/services/import.test.ts @@ -16,7 +16,17 @@ process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'chatcrystal-import-test-')); type ImportRuntime = { db: Database; - importAll: () => Promise<{ imported: number; skipped: number; errors: number }>; + importAll: () => Promise<{ + imported: number; + replaced: number; + skipped: number; + errors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; + }>; registerAdapter: (adapter: SourceAdapter) => void; appConfig: { enabledSources: string[] }; getUnsummarizedIds: () => string[]; @@ -310,7 +320,8 @@ test('importAll preserves user-rejected review links and gate state when reimpor ); const cleanupTasks = vectorCleanupRows(db); - assert.equal(progress.imported, 1); + assert.equal(progress.imported, 0); + assert.equal(progress.replaced, 1); assert.deepEqual(conversation, [ 'filtered', 86, @@ -379,7 +390,8 @@ test('importAll resets ordinary changed conversations to imported and clears sta ); const cleanupTasks = vectorCleanupRows(db); - assert.equal(progress.imported, 1); + assert.equal(progress.imported, 0); + assert.equal(progress.replaced, 1); assert.deepEqual(conversation, ['imported', null, null, null]); assert.deepEqual(messages, [ 'new ordinary user message', @@ -438,6 +450,7 @@ test('importAll skips changed files when parsed content hash is unchanged', asyn const cleanupTasks = vectorCleanupRows(db); assert.equal(progress.imported, 0); + assert.equal(progress.replaced, 0); assert.equal(progress.skipped, 1); assert.deepEqual(conversation, [ 99, @@ -450,6 +463,58 @@ test('importAll skips changed files when parsed content hash is unchanged', asyn assert.deepEqual(cleanupTasks, []); }); +test('importAll returns structured ids and excludes skipped from summary candidates', async () => { + const { db, importAll, registerAdapter, appConfig } = await loadRuntime(); + resetDatabase(db); + + const source = 'test-structured-import-ids'; + const skippedParsed = parsedConversation('codex-same', source, [ + 'hello', + 'world', + ]); + const skippedHash = computeConversationContentHash(skippedParsed); + + insertExistingConversation(db, { + id: 'codex-same', + source, + status: 'imported', + fileSize: 100, + fileMtime: '2026-05-23T00:00:00.000Z', + sourceConversationId: 'codex-same', + contentHash: skippedHash, + parserVersion: `${source}@test`, + }); + + registerAdapter( + testAdapter( + source, + [ + conversationMeta('codex-new', source, 100, '2026-05-23T00:00:00.000Z'), + conversationMeta('codex-same', source, 100, '2026-05-23T00:00:00.000Z'), + ], + new Map([ + [ + 'codex-new', + parsedConversation('codex-new', source, ['new user', 'new assistant']), + ], + ['codex-same', skippedParsed], + ]), + ), + ); + appConfig.enabledSources = [source]; + + const result = await importAll(); + + assert.equal(result.imported, 1); + assert.equal(result.replaced, 0); + assert.equal(result.skipped, 1); + assert.deepEqual(result.importedIds, ['codex-new']); + assert.deepEqual(result.replacedIds, []); + assert.deepEqual(result.skippedIds, ['codex-same']); + assert.deepEqual(result.errorIds, []); + assert.deepEqual(result.summarizationCandidateIds, ['codex-new']); +}); + test('importAll preserves edited notes when replacing changed local conversations', async () => { const { db, importAll, registerAdapter, appConfig, getUnsummarizedIds } = await loadRuntime(); @@ -503,7 +568,8 @@ test('importAll preserves edited notes when replacing changed local conversation )[0].values[0]; const cleanupTasks = vectorCleanupRows(db); - assert.equal(progress.imported, 1); + assert.equal(progress.imported, 0); + assert.equal(progress.replaced, 1); assert.deepEqual(messages, [ 'new edited-note user message', 'new edited-note assistant message', diff --git a/server/src/services/import.ts b/server/src/services/import.ts index 8060075..aa7cf48 100644 --- a/server/src/services/import.ts +++ b/server/src/services/import.ts @@ -1,4 +1,8 @@ -import type { ConversationMeta, ParsedConversation } from "@chatcrystal/shared"; +import type { + ConversationMeta, + ImportBatchResult, + ParsedConversation, +} from "@chatcrystal/shared"; import { appConfig } from "../config.js"; import { getDatabase, saveDatabase } from "../db/index.js"; import { withTransaction } from "../db/transaction.js"; @@ -6,13 +10,9 @@ import { getAdapter, getAllAdapters } from "../parser/index.js"; import { computeConversationContentHash } from "./importPayload.js"; import { enqueueNoteVectorCleanupTask } from "./vector-cleanup.js"; -export interface ImportProgress { - total: number; +export interface ImportProgress extends ImportBatchResult { current: number; currentFile: string; - imported: number; - skipped: number; - errors: number; } export type ProgressCallback = (progress: ImportProgress) => void; @@ -44,8 +44,14 @@ export async function importAll( current: 0, currentFile: "", imported: 0, + replaced: 0, skipped: 0, errors: 0, + importedIds: [], + replacedIds: [], + skippedIds: [], + errorIds: [], + summarizationCandidateIds: [], }; const db = getDatabase(); @@ -70,6 +76,7 @@ export async function importAll( existingMtime === meta.fileMtime ) { progress.skipped++; + progress.skippedIds.push(meta.id); continue; } } @@ -78,6 +85,7 @@ export async function importAll( const adapter = getAdapter(meta.adapterName); if (!adapter) { progress.errors++; + progress.errorIds.push(meta.id); continue; } @@ -88,6 +96,7 @@ export async function importAll( // Skip conversations with fewer than 2 meaningful messages if (parsed.messages.length < 2) { progress.skipped++; + progress.skippedIds.push(parsed.id); continue; } @@ -106,6 +115,7 @@ export async function importAll( ); }); progress.skipped++; + progress.skippedIds.push(parsed.id); continue; } @@ -123,9 +133,21 @@ export async function importAll( ); }); - progress.imported++; + if (existingRow) { + progress.replaced++; + progress.replacedIds.push(parsed.id); + const status = readConversationStatus(db, parsed.id); + if (status === 'imported') { + progress.summarizationCandidateIds.push(parsed.id); + } + } else { + progress.imported++; + progress.importedIds.push(parsed.id); + progress.summarizationCandidateIds.push(parsed.id); + } } catch (err) { progress.errors++; + progress.errorIds.push(meta.id); const errorMsg = err instanceof Error ? err.message : "Unknown error"; db.run( `INSERT INTO import_log (file_path, status, message) VALUES (?, 'error', ?)`, @@ -139,7 +161,7 @@ export async function importAll( saveDatabase(); console.log( - `[Import] Done: ${progress.imported} imported, ${progress.skipped} skipped, ${progress.errors} errors`, + `[Import] Done: ${progress.imported} imported, ${progress.replaced} replaced, ${progress.skipped} skipped, ${progress.errors} errors`, ); return progress; } @@ -190,6 +212,17 @@ function updateImportedConversationMetadata( ); } +function readConversationStatus( + db: ReturnType, + conversationId: string, +): string | null { + const row = db.exec( + 'SELECT status FROM conversations WHERE id = ?', + [conversationId], + )[0]?.values[0]; + return row ? String(row[0]) : null; +} + function deleteInvalidatedImportedNotes( db: ReturnType, conversationId: string, diff --git a/server/src/services/ingest.test.ts b/server/src/services/ingest.test.ts index 28cd6ea..09ebad7 100644 --- a/server/src/services/ingest.test.ts +++ b/server/src/services/ingest.test.ts @@ -144,6 +144,27 @@ test('ingestRemoteImport skips identical content hashes even when file metadata ]); }); +test('ingestRemoteImport returns summary candidate ids only for imported and replaced items', () => { + resetDatabase(db); + + const first = remoteItem('candidate-session'); + const initial = ingest.ingestRemoteImport({ version: 1, items: [first] }); + assert.deepEqual(initial.importedIds, [first.conversationId]); + assert.deepEqual(initial.replacedIds, []); + assert.deepEqual(initial.skippedIds, []); + assert.deepEqual(initial.summarizationCandidateIds, [first.conversationId]); + + const skipped = ingest.ingestRemoteImport({ version: 1, items: [first] }); + assert.deepEqual(skipped.importedIds, []); + assert.deepEqual(skipped.skippedIds, [first.conversationId]); + assert.deepEqual(skipped.summarizationCandidateIds, []); + + const changed = remoteItem('candidate-session', 'changed assistant response'); + const replaced = ingest.ingestRemoteImport({ version: 1, items: [changed] }); + assert.deepEqual(replaced.replacedIds, [first.conversationId]); + assert.deepEqual(replaced.summarizationCandidateIds, [first.conversationId]); +}); + test('ingestRemoteImport upgrades legacy local rows instead of duplicating them', () => { resetDatabase(db); db.run( diff --git a/server/src/services/ingest.ts b/server/src/services/ingest.ts index b9058b7..f5bd655 100644 --- a/server/src/services/ingest.ts +++ b/server/src/services/ingest.ts @@ -349,6 +349,14 @@ function ingestOne(db: Database, item: RemoteImportItem): RemoteImportItemResult }; } +function readConversationStatus(db: Database, conversationId: string): string | null { + const row = db.exec( + 'SELECT status FROM conversations WHERE id = ?', + [conversationId], + )[0]?.values[0]; + return row ? String(row[0]) : null; +} + export function ingestRemoteImport(request: RemoteImportRequest): RemoteImportResponse { if (request.version !== 1) { throw new Error('Unsupported remote import payload version'); @@ -374,12 +382,34 @@ export function ingestRemoteImport(request: RemoteImportRequest): RemoteImportRe saveDatabase(); + const importedIds = items + .filter((item) => item.status === 'imported') + .map((item) => item.conversationId); + const replacedIds = items + .filter((item) => item.status === 'replaced') + .map((item) => item.conversationId); + const skippedIds = items + .filter((item) => item.status === 'skipped') + .map((item) => item.conversationId); + const errorIds = items + .filter((item) => item.status === 'error') + .map((item) => item.conversationId) + .filter(Boolean); + const summarizationCandidateIds = [...importedIds, ...replacedIds].filter( + (id) => readConversationStatus(db, id) === 'imported', + ); + return { total: request.items.length, - imported: items.filter((item) => item.status === 'imported').length, - replaced: items.filter((item) => item.status === 'replaced').length, - skipped: items.filter((item) => item.status === 'skipped').length, + imported: importedIds.length, + replaced: replacedIds.length, + skipped: skippedIds.length, errors: items.filter((item) => item.status === 'error').length, + importedIds, + replacedIds, + skippedIds, + errorIds, + summarizationCandidateIds, items, }; } diff --git a/server/src/services/remoteImport.test.ts b/server/src/services/remoteImport.test.ts index ab47172..e7d6ab2 100644 --- a/server/src/services/remoteImport.test.ts +++ b/server/src/services/remoteImport.test.ts @@ -12,6 +12,7 @@ import { registerAdapter } from '../parser/index.js'; import { chunkRemoteImportItems, collectRemoteImportItems, + runRemoteImport, splitUploadableRemoteImportItems, validateRemoteImportSource, } from './remoteImport.js'; @@ -187,3 +188,38 @@ test('splitUploadableRemoteImportItems isolates oversized conversations', () => assert.deepEqual(result.uploadableItems.map((entry) => entry.sourceConversationId), ['small']); assert.deepEqual(result.oversizedItems.map((entry) => entry.sourceConversationId), ['large']); }); + +test('runRemoteImport accumulates structured ids returned by remote ingest', async () => { + registerAdapter(sourceAdapter('codex')); + appConfig.enabledSources = ['codex']; + + const result = await runRemoteImport({ + ingestConversations: async (request) => { + const ids = request.items.map((entry) => entry.conversationId); + return { + total: request.items.length, + imported: ids.length, + replaced: 0, + skipped: 0, + errors: 0, + importedIds: ids, + replacedIds: [], + skippedIds: [], + errorIds: [], + summarizationCandidateIds: ids, + items: request.items.map((entry) => ({ + source: entry.source, + sourceConversationId: entry.sourceConversationId, + conversationId: entry.conversationId, + status: 'imported', + })), + }; + }, + }); + + assert.deepEqual(result.importedIds, ['codex:codex-session']); + assert.deepEqual(result.replacedIds, []); + assert.deepEqual(result.skippedIds, []); + assert.deepEqual(result.errorIds, []); + assert.deepEqual(result.summarizationCandidateIds, ['codex:codex-session']); +}); diff --git a/server/src/services/remoteImport.ts b/server/src/services/remoteImport.ts index 0491b66..cfab87a 100644 --- a/server/src/services/remoteImport.ts +++ b/server/src/services/remoteImport.ts @@ -29,6 +29,11 @@ export type RemoteImportProgress = { export type RemoteImportResult = RemoteImportProgress & { localErrors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; }; type RemoteImportClient = { @@ -146,6 +151,11 @@ export async function runRemoteImport( skipped: 0, errors: collected.errors + oversizedItems.length, localErrors: collected.errors + oversizedItems.length, + importedIds: [], + replacedIds: [], + skippedIds: [], + errorIds: [], + summarizationCandidateIds: [], }; onProgress?.(progress); @@ -157,6 +167,11 @@ export async function runRemoteImport( progress.replaced += result.replaced; progress.skipped += result.skipped; progress.errors += result.errors; + progress.importedIds.push(...(result.importedIds ?? [])); + progress.replacedIds.push(...(result.replacedIds ?? [])); + progress.skippedIds.push(...(result.skippedIds ?? [])); + progress.errorIds.push(...(result.errorIds ?? [])); + progress.summarizationCandidateIds.push(...(result.summarizationCandidateIds ?? [])); onProgress?.(progress); } diff --git a/server/src/watcher/index.ts b/server/src/watcher/index.ts index facb6a6..5482a93 100644 --- a/server/src/watcher/index.ts +++ b/server/src/watcher/index.ts @@ -17,8 +17,10 @@ async function runImport() { isImporting = true; try { const result = await importAll(); - if (result.imported > 0) { - console.log(`[Watcher] Auto-imported ${result.imported} conversations`); + if (result.imported > 0 || result.replaced > 0) { + console.log( + `[Watcher] Auto-imported ${result.imported} new, ${result.replaced} updated conversations`, + ); } } catch (err) { console.error( diff --git a/shared/types/index.ts b/shared/types/index.ts index 3cce733..d42525c 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -428,6 +428,19 @@ export interface RemoteImportRequest { items: RemoteImportItem[]; } +export interface ImportBatchResult { + total: number; + imported: number; + replaced: number; + skipped: number; + errors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; +} + export type RemoteImportItemStatus = 'imported' | 'replaced' | 'skipped' | 'error'; export interface RemoteImportItemResult { @@ -444,5 +457,51 @@ export interface RemoteImportResponse { replaced: number; skipped: number; errors: number; + importedIds: string[]; + replacedIds: string[]; + skippedIds: string[]; + errorIds: string[]; + summarizationCandidateIds: string[]; items: RemoteImportItemResult[]; } + +export interface SummarizeByIdsRequest { + conversationIds: string[]; +} + +export interface SummarizeByIdsResponse { + queued: number; + skipped: string[]; + unknown: string[]; + queue: TaskSnapshot; +} + +export interface ConversationSummaryStatus { + id: string; + status: ConversationStatus | 'unknown'; +} + +export type TaskStatus = 'queued' | 'processing' | 'completed' | 'failed'; + +export interface TaskEntry { + id: string; + title: string; + status: TaskStatus; + error?: string; + addedAt: number; + startedAt?: number; + finishedAt?: number; +} + +export interface TaskSnapshot { + total: number; + completed: number; + failed: number; + active: number; + tasks: TaskEntry[]; +} + +export interface SummarizeStatusByIdsResponse { + items: ConversationSummaryStatus[]; + queue: TaskSnapshot; +}