From 9786274ab9608e912c58098c10fc36e3e877989e Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 28 Jun 2026 18:05:51 +0900 Subject: [PATCH 1/6] fix(server): sync PostgreSQL and LanceDB discrepancies on startup --- .../services/lancedb-dump-service.ts | 1 + .../services/maintenance-service.ts | 87 +++++++++-- .../services/maintenance-service.test.ts | 144 +++++++++++++++++- .../src/ports/lancedb-dump-service.ts | 1 + .../src/services/lancedb-dump-service.ts | 12 ++ 5 files changed, 234 insertions(+), 11 deletions(-) diff --git a/apps/server/src/application/services/lancedb-dump-service.ts b/apps/server/src/application/services/lancedb-dump-service.ts index 5b6a2223..5cca837e 100644 --- a/apps/server/src/application/services/lancedb-dump-service.ts +++ b/apps/server/src/application/services/lancedb-dump-service.ts @@ -19,4 +19,5 @@ export const syncLanceDB = service.syncLanceDB; export const syncLanceDBPages = service.syncLanceDBPages; export const syncLanceDBDelta = service.syncLanceDBDelta; export const readFromLanceDB = service.readFromLanceDB; +export const readMediaIds = service.readMediaIds; export const cleanupLanceDBDir = service.cleanupLanceDBDir; diff --git a/apps/server/src/application/services/maintenance-service.ts b/apps/server/src/application/services/maintenance-service.ts index de4bff37..53f6b43b 100644 --- a/apps/server/src/application/services/maintenance-service.ts +++ b/apps/server/src/application/services/maintenance-service.ts @@ -100,9 +100,74 @@ export class MaintenanceService { let queuedCount = 0; for (const source of sources) { - const jobType = (await this.hasLanceDbCache(source.id)) - ? "sync_lancedb_delta" - : "sync_lancedb_full"; + const hasCache = await this.hasLanceDbCache(source.id); + let jobType: "sync_lancedb_full" | "sync_lancedb_delta" = + "sync_lancedb_full"; + + if (hasCache) { + try { + const cacheDir = await this.getLanceDbCacheDir(source.id); + const { readMediaIds } = await import( + "~/application/services/lancedb-dump-service" + ); + const lanceDbIds = await readMediaIds(cacheDir); + const postgresMedias = await this.mediaRepo.findAllPathsBySourceId( + source.id, + ); + + const postgresIdsSet = new Set(postgresMedias.map((m) => m.id)); + const lanceDbIdsSet = new Set(lanceDbIds); + + // PostgreSQL にあって LanceDB にない -> 追加 (upsert) + const toUpsert = postgresMedias + .filter((m) => !lanceDbIdsSet.has(m.id)) + .map((m) => m.id); + + // LanceDB にあって PostgreSQL にない -> 削除 (delete) + const toDelete = lanceDbIds.filter((id) => !postgresIdsSet.has(id)); + + if (toUpsert.length > 0 || toDelete.length > 0) { + logger.info( + { + sourceId: source.id, + toUpsertCount: toUpsert.length, + toDeleteCount: toDelete.length, + }, + "Found discrepancies between PostgreSQL and LanceDB on startup. Queueing delta sync.", + ); + + const { BackupService } = await import( + "~/application/services/backup-service" + ); + + if (toUpsert.length > 0) { + await BackupService.queueSourceLanceDBDelta( + source.id, + toUpsert, + "upsert", + { enqueueJob: false }, + ); + } + if (toDelete.length > 0) { + await BackupService.queueSourceLanceDBDelta( + source.id, + toDelete, + "delete", + { enqueueJob: false }, + ); + } + } + + jobType = "sync_lancedb_delta"; + } catch (compareError) { + logger.error( + { err: compareError, sourceId: source.id }, + "Failed to compare PostgreSQL and LanceDB on startup. Falling back to full sync.", + ); + jobType = "sync_lancedb_full"; + } + } + const created = await this.jobRepo.createIfUnique({ type: jobType, mediaSourceId: source.id, @@ -124,14 +189,16 @@ export class MaintenanceService { } } + private async getLanceDbCacheDir(sourceId: string): Promise { + const { services } = await import("~/application/registry"); + const config = services.getConfigService().getConfig(); + const baseCacheDir = config.lancedb?.cacheDir ?? ".cache/lancedb-cache"; + return path.resolve(process.cwd(), baseCacheDir, `source-${sourceId}`); + } + private async hasLanceDbCache(sourceId: string): Promise { - const manifestPath = path.join( - process.cwd(), - ".cache", - "lancedb-cache", - `source-${sourceId}`, - "manifest.json", - ); + const cacheDir = await this.getLanceDbCacheDir(sourceId); + const manifestPath = path.join(cacheDir, "manifest.json"); try { const content = await fs.readFile(manifestPath, "utf-8"); const manifest = JSON.parse(content) as { version?: unknown }; diff --git a/apps/server/src/tests/unit/application/services/maintenance-service.test.ts b/apps/server/src/tests/unit/application/services/maintenance-service.test.ts index ea634a9b..c0e02591 100644 --- a/apps/server/src/tests/unit/application/services/maintenance-service.test.ts +++ b/apps/server/src/tests/unit/application/services/maintenance-service.test.ts @@ -12,10 +12,11 @@ import { MaintenanceService } from "~/application/services/maintenance-service"; // ---- Module mocks ---- -// Mock node:fs/promises to control thumbnail directory reads +// Mock node:fs/promises to control thumbnail directory reads and lanceDb manifest reads vi.mock("node:fs/promises", () => ({ default: { readdir: vi.fn(), + readFile: vi.fn(), }, })); @@ -24,10 +25,38 @@ vi.mock("~/infrastructure/jobs/thumbnails", () => ({ getSourceCacheDir: vi.fn((sourceId: string) => `/cache/${sourceId}`), })); +// Mock registry for configuration loading +vi.mock("~/application/registry", () => ({ + services: { + getConfigService: () => ({ + getConfig: () => ({ + lancedb: { + cacheDir: ".cache/lancedb-cache", + }, + }), + }), + }, +})); + +// Mock lancedb-dump-service +const mockReadMediaIds = vi.fn(); +vi.mock("~/application/services/lancedb-dump-service", () => ({ + readMediaIds: mockReadMediaIds, +})); + +// Mock backup-service +const mockQueueSourceLanceDBDelta = vi.fn(); +vi.mock("~/application/services/backup-service", () => ({ + BackupService: { + queueSourceLanceDBDelta: mockQueueSourceLanceDBDelta, + }, +})); + // Silence logger output during tests vi.mock("~/infrastructure/logger", () => ({ logger: { info: vi.fn(), + debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }, @@ -38,6 +67,7 @@ vi.mock("~/infrastructure/logger", () => ({ const mockMediaRepo = { findIdsWithMissingGenerationInfo: vi.fn(), findAllMediaIndices: vi.fn(), + findAllPathsBySourceId: vi.fn(), }; const mockJobRepo = { @@ -77,10 +107,13 @@ describe("MaintenanceService", () => { mockSourceRepo as any, ); mockSourceRepo.findAll.mockResolvedValue([]); + mockMediaRepo.findAllPathsBySourceId.mockResolvedValue([]); }); afterEach(() => { vi.clearAllMocks(); + mockReadMediaIds.mockReset(); + mockQueueSourceLanceDBDelta.mockReset(); }); // -------------------------------------------------------------------------- @@ -325,6 +358,10 @@ describe("MaintenanceService", () => { ]); mockJobRepo.createIfUnique.mockResolvedValue({ id: "job-new" }); + // Simulate manifest.json doesn't exist (ENOENT) + const err = Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + (fs.readFile as unknown as Mock).mockRejectedValue(err); + await service.performStartupChecks(); expect(mockSourceRepo.findAll).toHaveBeenCalledOnce(); @@ -341,5 +378,110 @@ describe("MaintenanceService", () => { }), ); }); + + it("should queue delta sync job without queueing delta details if PostgreSQL and LanceDB are in sync", async () => { + mockMediaRepo.findIdsWithMissingGenerationInfo.mockResolvedValue([]); + mockMediaRepo.findAllMediaIndices.mockResolvedValue([]); + mockSourceRepo.findAll.mockResolvedValue([ + makeLocalSource("source-1", "/path-1"), + ]); + mockJobRepo.createIfUnique.mockResolvedValue({ id: "job-new" }); + + // Simulate manifest.json exists (version: 3) + (fs.readFile as unknown as Mock).mockResolvedValue( + JSON.stringify({ version: 3 }), + ); + + // Both LanceDB and Postgres have media-1 + mockReadMediaIds.mockResolvedValue(["media-1"]); + mockMediaRepo.findAllPathsBySourceId.mockResolvedValue([ + { id: "media-1", filePath: "/media/media-1.png" }, + ]); + + await service.performStartupChecks(); + + expect(mockQueueSourceLanceDBDelta).not.toHaveBeenCalled(); + expect(mockJobRepo.createIfUnique).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sync_lancedb_delta", + mediaSourceId: "source-1", + }), + ); + }); + + it("should queue discrepancies to delta table and trigger delta sync job if discrepancies are found", async () => { + mockMediaRepo.findIdsWithMissingGenerationInfo.mockResolvedValue([]); + mockMediaRepo.findAllMediaIndices.mockResolvedValue([]); + mockSourceRepo.findAll.mockResolvedValue([ + makeLocalSource("source-1", "/path-1"), + ]); + mockJobRepo.createIfUnique.mockResolvedValue({ id: "job-new" }); + + // Simulate manifest.json exists (version: 3) + (fs.readFile as unknown as Mock).mockResolvedValue( + JSON.stringify({ version: 3 }), + ); + + // Postgres: media-1, media-2 + // LanceDB: media-2, media-3 + // Discrepancies: upsert [media-1], delete [media-3] + mockReadMediaIds.mockResolvedValue(["media-2", "media-3"]); + mockMediaRepo.findAllPathsBySourceId.mockResolvedValue([ + { id: "media-1", filePath: "/media/media-1.png" }, + { id: "media-2", filePath: "/media/media-2.png" }, + ]); + + await service.performStartupChecks(); + + expect(mockQueueSourceLanceDBDelta).toHaveBeenCalledTimes(2); + expect(mockQueueSourceLanceDBDelta).toHaveBeenNthCalledWith( + 1, + "source-1", + ["media-1"], + "upsert", + { enqueueJob: false }, + ); + expect(mockQueueSourceLanceDBDelta).toHaveBeenNthCalledWith( + 2, + "source-1", + ["media-3"], + "delete", + { enqueueJob: false }, + ); + + expect(mockJobRepo.createIfUnique).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sync_lancedb_delta", + mediaSourceId: "source-1", + }), + ); + }); + + it("should fall back to full sync if comparison throws an error", async () => { + mockMediaRepo.findIdsWithMissingGenerationInfo.mockResolvedValue([]); + mockMediaRepo.findAllMediaIndices.mockResolvedValue([]); + mockSourceRepo.findAll.mockResolvedValue([ + makeLocalSource("source-1", "/path-1"), + ]); + mockJobRepo.createIfUnique.mockResolvedValue({ id: "job-new" }); + + // Simulate manifest.json exists (version: 3) + (fs.readFile as unknown as Mock).mockResolvedValue( + JSON.stringify({ version: 3 }), + ); + + // readMediaIds throws error + mockReadMediaIds.mockRejectedValue(new Error("LanceDB read error")); + + await service.performStartupChecks(); + + expect(mockQueueSourceLanceDBDelta).not.toHaveBeenCalled(); + expect(mockJobRepo.createIfUnique).toHaveBeenCalledWith( + expect.objectContaining({ + type: "sync_lancedb_full", + mediaSourceId: "source-1", + }), + ); + }); }); }); diff --git a/packages/application/src/ports/lancedb-dump-service.ts b/packages/application/src/ports/lancedb-dump-service.ts index d37f8b84..43f1645d 100644 --- a/packages/application/src/ports/lancedb-dump-service.ts +++ b/packages/application/src/ports/lancedb-dump-service.ts @@ -58,5 +58,6 @@ export interface ILanceDbDumpService { lanceDbDir: string, options?: ReadOptions, ): Promise; + readMediaIds(lanceDbDir: string): Promise; cleanupLanceDBDir(dir: string): Promise; } diff --git a/packages/application/src/services/lancedb-dump-service.ts b/packages/application/src/services/lancedb-dump-service.ts index b6c860c2..d73110dd 100644 --- a/packages/application/src/services/lancedb-dump-service.ts +++ b/packages/application/src/services/lancedb-dump-service.ts @@ -845,6 +845,17 @@ export function createLanceDbDumpService(deps?: { return allItems; } + async function readMediaIds(lanceDbDir: string): Promise { + const connect = await getConnect(); + const db = await connect(lanceDbDir); + const mediaTable = await db.openTable("media"); + const rows = await mediaTable.query().select(["id"]).toArray(); + return rows.flatMap((row) => { + const id = safeString(row.id); + return id ? [id] : []; + }); + } + async function cleanupLanceDBDir(dir: string): Promise { await fs.rm(dir, { recursive: true, force: true }); } @@ -855,6 +866,7 @@ export function createLanceDbDumpService(deps?: { syncLanceDBPages, syncLanceDBDelta, readFromLanceDB, + readMediaIds, cleanupLanceDBDir, }; } From 4499eedb464ec4a1c44e4e70f6adc6a929fb8310 Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 28 Jun 2026 22:27:38 +0900 Subject: [PATCH 2/6] feat(ai): add CCIP vector extraction and similarity search --- .agents/skills/ai-service/SKILL.md | 2 + .agents/skills/job-system/SKILL.md | 34 +++ .agents/skills/job-system/agents/openai.yaml | 4 + .agents/skills/media-search/SKILL.md | 37 ++++ .../skills/media-search/agents/openai.yaml | 4 + .agents/skills/solid-imager/SKILL.md | 2 + AGENTS.md | 2 + apps/server/public/openapi.json | 130 ++++++++++++ .../services/ccip-vector-service.ts | 27 +++ .../services/directory-sync-service.ts | 9 + .../services/job-dispatch-service.ts | 5 + .../services/media-processing-service.ts | 5 +- .../services/media-source-service.ts | 2 + .../src/components/media/media-sidebar.tsx | 72 ++++++- .../ai/lancedb-ccip-vector-store.ts | 194 ++++++++++++++++++ .../src/infrastructure/ai/rust-ai-client.ts | 33 +++ .../src/infrastructure/api-clients/ai-api.ts | 27 +++ .../infrastructure/api-clients/search-api.ts | 8 + .../infrastructure/api/routers/ai-router.ts | 153 +++++++++++++- .../api/routers/media-router.ts | 18 ++ apps/server/src/infrastructure/bootstrap.ts | 2 + .../src/infrastructure/jobs/ccip-jobs.ts | 68 ++++++ .../jobs/file-watcher-service.ts | 9 + .../src/infrastructure/jobs/job-worker.ts | 5 +- apps/server/src/routes/manager.tsx | 4 + apps/server/src/routes/search.tsx | 9 +- .../services/ccip-vector-service.test.ts | 96 +++++++++ .../services/directory-sync-service.test.ts | 1 + .../infrastructure/jobs/job-worker.test.ts | 16 +- .../media-sidebar/media-sidebar-content.tsx | 21 ++ .../infrastructure/api-clients/search-api.ts | 8 + apps/tauri/src/routes/manager.tsx | 8 + apps/tauri/src/routes/search.tsx | 9 +- .../src/ports/ccip-vector-store.ts | 27 +++ packages/application/src/ports/index.ts | 5 + .../src/ports/media-processing-service.ts | 5 +- .../application/src/ports/tagging-service.ts | 4 + .../src/services/ccip-vector-service.ts | 179 ++++++++++++++++ packages/application/src/services/index.ts | 5 + .../src/services/media-processing-service.ts | 26 ++- .../src/services/tagging-service.ts | 7 + .../core/src/domain/config/config-schema.ts | 5 + .../core/src/domain/contract/ai.contract.ts | 26 +++ .../src/domain/contract/media.contract.ts | 6 + .../core/src/domain/interfaces/ai-client.ts | 5 + packages/core/src/domain/media/schemas.ts | 16 ++ packages/core/src/domain/search/logic.ts | 6 +- packages/core/src/domain/search/schema.ts | 6 +- packages/core/src/domain/tagging/schemas.ts | 44 ++++ .../db/src/repositories/job-repository.ts | 8 +- .../hooks/use-current-search-persistence.ts | 20 +- packages/ui/src/hooks/use-manager-page.ts | 68 +++++- packages/ui/src/hooks/use-search-page.ts | 34 ++- packages/ui/src/media-sidebar-content.tsx | 7 + packages/ui/src/media-sidebar.tsx | 55 +++++ packages/ui/src/preset-manager.tsx | 2 +- packages/ui/src/screens/config-screen.tsx | 16 ++ packages/ui/src/screens/manager-screen.tsx | 48 ++++- packages/ui/src/search-control-panel.tsx | 60 +++++- packages/ui/src/stores/search-store.ts | 24 ++- 60 files changed, 1686 insertions(+), 52 deletions(-) create mode 100644 .agents/skills/job-system/SKILL.md create mode 100644 .agents/skills/job-system/agents/openai.yaml create mode 100644 .agents/skills/media-search/SKILL.md create mode 100644 .agents/skills/media-search/agents/openai.yaml create mode 100644 apps/server/src/application/services/ccip-vector-service.ts create mode 100644 apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts create mode 100644 apps/server/src/infrastructure/jobs/ccip-jobs.ts create mode 100644 apps/server/src/tests/unit/application/services/ccip-vector-service.test.ts create mode 100644 packages/application/src/ports/ccip-vector-store.ts create mode 100644 packages/application/src/services/ccip-vector-service.ts diff --git a/.agents/skills/ai-service/SKILL.md b/.agents/skills/ai-service/SKILL.md index e74e67d5..cc6dd057 100644 --- a/.agents/skills/ai-service/SKILL.md +++ b/.agents/skills/ai-service/SKILL.md @@ -22,3 +22,5 @@ description: 画像の自動タグ付け、類似度計算、CCIP特徴量など |---|---| | タグ付け機能の実装 | `tagging-service.ts` を経由して `RustAiClient` を呼び出し | | 類似度計算 | CCIP特徴量を使用したサービス層の実装 | + +CCIP抽出をjob化する場合は`job-system`、検索画面へ統合する場合は`media-search`も参照する。 diff --git a/.agents/skills/job-system/SKILL.md b/.agents/skills/job-system/SKILL.md new file mode 100644 index 00000000..d9671b7c --- /dev/null +++ b/.agents/skills/job-system/SKILL.md @@ -0,0 +1,34 @@ +--- +name: job-system +description: solid-imagerのbackground job、JobWorker、job dispatch、AI concurrency、親子batch進捗、stale recoveryを扱う。job type追加、非同期処理、Managerのbatch操作、job event変更時に使用する。 +--- + +# Job System + +## データフロー + +1. jobは`IJobRepository.create`または`createIfUnique`で投入する。 +2. `apps/server/src/infrastructure/jobs/job-worker.ts`がAI jobとその他jobを別poolでclaimする。 +3. `apps/server/src/application/services/job-dispatch-service.ts`がjob typeごとのhandlerへ振り分ける。 +4. handlerはpayloadをZod schemaでparseしてから処理する。 +5. UIへ進捗を出す場合は`RealtimeEventBus.publishJob`だけを使用する。 + +## Job追加時の必須更新 + +- dispatchへ明示的な分岐を追加する。未知jobは警告後に完了扱いになるため、登録漏れを残さない。 +- AI推論を行うjobは`JobWorker.aiJobTypes`へ追加し、`jobs.aiConcurrency`の対象にする。 +- payload schema、成功・失敗、claim pool、stale recoveryのunit testを追加する。 +- source単位で直列化が必要なjobはLanceDB syncと同様にclaim条件とactive keyを実装する。 +- media lifecycleから投入する場合はupload、watcher、copy/move、delete、bulk操作を監査する。 + +## Batch Job + +- 親jobは進捗記録でありworkerに実行させない。`status: in_progress`で作成する。 +- 子jobへ`parentId`を設定し、子の完了時に親payloadの`processed`を原子的に更新する。 +- 親更新では`updatedAt`も更新する。 +- job event schemaは`packages/core/src/domain/sources/events.ts`を唯一の正とする。 +- clientは既存のjob event hookを再利用し、独自pollingやEventSourceを追加しない。 + +## 検証 + +`claimPending`のinclude/exclude、AI concurrency、親job非claim、進捗完了、失敗、stale job回復を確認する。 diff --git a/.agents/skills/job-system/agents/openai.yaml b/.agents/skills/job-system/agents/openai.yaml new file mode 100644 index 00000000..60048250 --- /dev/null +++ b/.agents/skills/job-system/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Job System" + short_description: "solid-imagerのbackground job設計と実装規約" + default_prompt: "既存のjob worker、dispatch、進捗イベント設計に従って変更してください。" diff --git a/.agents/skills/media-search/SKILL.md b/.agents/skills/media-search/SKILL.md new file mode 100644 index 00000000..b76086bb --- /dev/null +++ b/.agents/skills/media-search/SKILL.md @@ -0,0 +1,37 @@ +--- +name: media-search +description: solid-imagerのメディア検索schema、simple/pro/vector mode、shared search store、session persistence、preset、検索API、無限スクロールを扱う。検索条件・mode・sort・類似検索・検索画面変更時に使用する。 +--- + +# Media Search + +## 既存設計 + +- schemaと型の正は`packages/core/src/domain/search/`とmedia schemaに置く。 +- stateの正は`packages/ui/src/stores/search-store.ts`。server/Tauriは同じstoreをre-exportする。 +- 検索状態はstoreと`use-current-search-persistence`の`sessionStorage`で保持する。URL queryへ別系統の状態を追加しない。 +- simple/pro変換はcoreのsearch logicを経由する。 +- user presetとcurrent session stateを区別する。一時的な類似元などを通常presetへ混ぜない。 +- server/Tauri共通表示は`packages/ui`へ置き、app側はAPI clientとroute wiringだけを持つ。 + +## 変更手順 + +1. Zod `SearchState`とdefault stateを更新する。 +2. mode遷移、condition生成、preset復元への影響を確認する。 +3. session保存対象と復元処理を同時に更新する。 +4. oRPC contract、router、application/repositoryの順で検索処理を追加する。 +5. `useSearchPage`のquery keyへ結果を変える全stateを含める。 +6. paginationを使わないmodeではnext pageを返さない。 +7. 大量画像は既存のvirtualized gridとlazy loadingを維持する。 + +## Vector類似検索 + +- `vector`はsimple/proと独立した第三modeとして扱う。 +- 個別media画面はstoreへanchor IDを設定して`/search`へ通常遷移する。 +- URL queryは使用しない。 +- source filterとtopKを明示し、通常sortは適用しない。 +- CCIPはキャラクター類似であり一般的な重複画像検索ではないことをUIに表示する。 + +## 検証 + +mode遷移、session復元、preset非汚染、query key、server/Tauri parity、空結果、エラー、無限scroll停止を確認する。 diff --git a/.agents/skills/media-search/agents/openai.yaml b/.agents/skills/media-search/agents/openai.yaml new file mode 100644 index 00000000..db85cef6 --- /dev/null +++ b/.agents/skills/media-search/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Media Search" + short_description: "solid-imagerの検索状態・API・UI設計規約" + default_prompt: "既存の検索store、session persistence、API、共有UI設計に従って変更してください。" diff --git a/.agents/skills/solid-imager/SKILL.md b/.agents/skills/solid-imager/SKILL.md index cd775268..02b6ba94 100644 --- a/.agents/skills/solid-imager/SKILL.md +++ b/.agents/skills/solid-imager/SKILL.md @@ -97,6 +97,8 @@ bun run test - UI 変更: `ui-components`, `tanstack-db`, 必要に応じて `modern-web-guidance` - リアルタイムイベント/SSE 変更: `realtime-events`, `orpc-api`, `schema-driven-dev` - AI/ML 連携: `ai-service` +- Background job・batch進捗: `job-system` +- メディア検索・検索状態・類似検索: `media-search` - CLI 変更: `cli` - ブラウザ拡張: `browser-extension` - ログ実装・整理: `logging-rules` diff --git a/AGENTS.md b/AGENTS.md index 15fb9689..58a37b03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,8 @@ | `issue-driven` | GitHub Issue駆動開発ワークフロー(証跡・進捗管理) | issueをベースに開発作業を始めるとき | | `tanstack-db` | TanStack DBクライアントデータレイヤー(永続化、useLiveQuery、includes) | クライアント側データレイヤー変更時 | | `realtime-events` | 型付きリアルタイムイベント、oRPC Event Iterator、pub/sub、再接続 | SSE・イベント配信・購読・イベントschema変更時 | +| `job-system` | background job、worker、dispatch、AI concurrency、batch親子進捗 | job type追加・非同期処理・batch操作変更時 | +| `media-search` | 検索schema、shared store、session persistence、preset、類似検索 | 検索条件・mode・検索画面変更時 | diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json index 64ebbca7..1c85431c 100644 --- a/apps/server/public/openapi.json +++ b/apps/server/public/openapi.json @@ -613,6 +613,32 @@ } } }, + "/media/searchSimilar": { + "post": { + "operationId": "media.searchSimilar", + "summary": "searchSimilar", + "tags": [ + "Media" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, "/media/get": { "post": { "operationId": "media.get", @@ -2148,6 +2174,110 @@ } } }, + "/ai/ccipVectorStatus": { + "post": { + "operationId": "ai.ccipVectorStatus", + "summary": "ccipVectorStatus", + "tags": [ + "AI" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, + "/ai/startCcipExtraction": { + "post": { + "operationId": "ai.startCcipExtraction", + "summary": "startCcipExtraction", + "tags": [ + "AI" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, + "/ai/scanBatchCcipTargets": { + "post": { + "operationId": "ai.scanBatchCcipTargets", + "summary": "scanBatchCcipTargets", + "tags": [ + "AI" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, + "/ai/startBatchCcipExtraction": { + "post": { + "operationId": "ai.startBatchCcipExtraction", + "summary": "startBatchCcipExtraction", + "tags": [ + "AI" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, "/ai/detectAndCropCharacters": { "post": { "operationId": "ai.detectAndCropCharacters", diff --git a/apps/server/src/application/services/ccip-vector-service.ts b/apps/server/src/application/services/ccip-vector-service.ts new file mode 100644 index 00000000..4198ef21 --- /dev/null +++ b/apps/server/src/application/services/ccip-vector-service.ts @@ -0,0 +1,27 @@ +import { CcipVectorService } from "@solid-imager/application/services/ccip-vector-service"; +import { services } from "~/application/registry"; +import { taggingService } from "~/application/services/tagging-service"; +import { LanceDbCcipVectorStore } from "~/infrastructure/ai/lancedb-ccip-vector-store"; + +let service: CcipVectorService | null = null; + +export function getCcipVectorService(): CcipVectorService { + if (!service) { + const config = services.getConfigService().getConfig(); + service = new CcipVectorService({ + mediaRepository: services.getMediaRepository(), + sourceRepository: services.getSourceRepository(), + taggingService, + vectorStore: new LanceDbCcipVectorStore(config.lancedb.ccipVectorDir), + }); + } + return service; +} + +export const ccipVectorService = new Proxy({} as CcipVectorService, { + get(_target, property) { + const instance = getCcipVectorService(); + const value = instance[property as keyof CcipVectorService]; + return typeof value === "function" ? value.bind(instance) : value; + }, +}); diff --git a/apps/server/src/application/services/directory-sync-service.ts b/apps/server/src/application/services/directory-sync-service.ts index 22626efa..ebcf56a9 100644 --- a/apps/server/src/application/services/directory-sync-service.ts +++ b/apps/server/src/application/services/directory-sync-service.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Glob } from "bun"; import { services } from "~/application/registry"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { MediaProcessingService } from "~/application/services/media-processing-service"; import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; import { deleteThumbnail } from "~/infrastructure/jobs/thumbnails"; @@ -60,6 +61,14 @@ async function processDeletions( filesToDelete.map(async (fileToDelete) => { try { await MediaRepository.delete(fileToDelete.id); + try { + await ccipVectorService.delete(fileToDelete.id); + } catch (error) { + logger.warn( + { err: error, mediaId: fileToDelete.id }, + "Failed to delete CCIP vector during directory sync", + ); + } await deleteThumbnail(mediaSourceId, fileToDelete.id); RealtimeEventBus.publishSource(mediaSourceId, "media-deleted", { filePath: fileToDelete.relativePath, diff --git a/apps/server/src/application/services/job-dispatch-service.ts b/apps/server/src/application/services/job-dispatch-service.ts index ca4131c9..db95dd30 100644 --- a/apps/server/src/application/services/job-dispatch-service.ts +++ b/apps/server/src/application/services/job-dispatch-service.ts @@ -28,6 +28,11 @@ export async function processJob(job: DbJob) { await processDownloadJob(job); } else if (job.type === "auto_tagging") { await processAutoTaggingJob(job); + } else if (job.type === "extract_ccip_vector") { + const { processCcipExtractionJob } = await import( + "~/infrastructure/jobs/ccip-jobs" + ); + await processCcipExtractionJob(job); } else if (job.type === "bulk_tagging_dispatch") { await processBulkTaggingDispatchJob(job); } else if (job.type === "sync_lancedb" || job.type === "sync_lancedb_full") { diff --git a/apps/server/src/application/services/media-processing-service.ts b/apps/server/src/application/services/media-processing-service.ts index 6bf91976..d6abe76b 100644 --- a/apps/server/src/application/services/media-processing-service.ts +++ b/apps/server/src/application/services/media-processing-service.ts @@ -34,7 +34,10 @@ export const MediaProcessingService = { .addContextMetadataToExistingMedia(mediaId, context, tx); }, - updateConfig: (config: { enableAutoTagging: boolean }) => { + updateConfig: (config: { + enableAutoTagging: boolean; + enableAutoCcipExtraction: boolean; + }) => { return services.getMediaProcessingService().updateConfig(config); }, }; diff --git a/apps/server/src/application/services/media-source-service.ts b/apps/server/src/application/services/media-source-service.ts index a0117445..1765ecd0 100644 --- a/apps/server/src/application/services/media-source-service.ts +++ b/apps/server/src/application/services/media-source-service.ts @@ -2,6 +2,7 @@ import type { MediaSource, NewMediaSource, } from "@solid-imager/core/domain/repositories/source-repository"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { DrizzleSourceRepository } from "~/infrastructure/repositories/source-repository"; /** @@ -59,6 +60,7 @@ const deleteSourceServer = async ( // We fetch it first before deleting to satisfy the return type if needed. const source = await sourceRepo.findById(mediaSourceId); await sourceRepo.delete(mediaSourceId); + await ccipVectorService.deleteBySource(mediaSourceId); return source ? [source] : []; }; diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index dda515e7..dd28299e 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -3,13 +3,18 @@ import { getErrorMessage } from "@solid-imager/core/utils"; import { Badge } from "@solid-imager/ui/badge"; import { ClipboardCopy } from "@solid-imager/ui/clipboard-copy"; import { CollapsibleRoot as Collapsible } from "@solid-imager/ui/collapsible"; +import { activateVectorSearch } from "@solid-imager/ui/stores/search-store"; import { toast } from "@solid-imager/ui/toast"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import { createMemo, createSignal, For, Show } from "solid-js"; +import { useNavigate } from "@tanstack/solid-router"; +import { createMemo, createSignal, For, onMount, Show } from "solid-js"; import { AiTaggingModal } from "~/components/media/ai-tagging-modal"; import AssociationManager from "~/components/media/association-manager"; import CharacterCropModal from "~/components/media/character-crop-modal"; - +import { + getCcipVectorStatus, + startCcipExtraction, +} from "~/infrastructure/api-clients/ai-api"; import { addCharacterToMedia, createCharacter, @@ -20,15 +25,12 @@ import { createIp, removeIpFromMedia, } from "~/infrastructure/api-clients/ips-api"; - import { updateMedia } from "~/infrastructure/api-clients/media-api"; - import { addProjectToMedia, createProject, removeProjectFromMedia, } from "~/infrastructure/api-clients/projects-api"; - import { allCharactersQueryOptions, allIpsQueryOptions, @@ -82,11 +84,45 @@ const _CollapsibleSection = (props: { export function MediaSidebar(props: MediaSidebarProps) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const tags = createMemo(() => props.media.tags || []); const [isAiTaggingModalOpen, setIsAiTaggingModalOpen] = createSignal(false); const [isCharacterCropModalOpen, setIsCharacterCropModalOpen] = createSignal(false); + const [ccipStatus, setCcipStatus] = createSignal< + "missing" | "processing" | "ready" | "stale" | "failed" + >("missing"); + const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); + + onMount(async () => { + try { + const result = await getCcipVectorStatus( + props.media.mediaSourceId, + props.media.id, + ); + setCcipStatus(result.status); + } catch { + setCcipStatus("failed"); + } + }); + + const handleCcipExtraction = async () => { + setIsExtractingCcip(true); + try { + await startCcipExtraction( + props.media.mediaSourceId, + props.media.id, + ccipStatus() === "ready" || ccipStatus() === "stale", + ); + setCcipStatus("processing"); + toast.success("CCIP vector extraction queued"); + } catch (error) { + toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); + } finally { + setIsExtractingCcip(false); + } + }; // Description editing state const [isEditingDescription, setIsEditingDescription] = createSignal(false); @@ -263,6 +299,32 @@ export function MediaSidebar(props: MediaSidebarProps) { Detect & Crop Characters + + + + { + if (Array.isArray(value)) return value; + if ( + typeof value === "object" && + value !== null && + Symbol.iterator in value + ) { + // Apache Arrow returns a Vector object at this external boundary. + return Array.from(value as Iterable); + } + return value; + }, z.array(z.number()).length(VECTOR_DIMENSIONS)), + model: z.string(), + embeddingVersion: z.number().int(), + mediaModifiedAt: z.coerce.date(), + extractedAt: z.coerce.date(), + _distance: z.number().optional(), +}); + +function escapeSqlString(value: string): string { + return value.replaceAll("'", "''"); +} + +function toRecord(value: unknown): CcipVectorRecord { + const row = rowSchema.parse(value); + return { + mediaId: row.mediaId, + mediaSourceId: row.mediaSourceId, + vector: row.vector, + model: row.model, + embeddingVersion: row.embeddingVersion, + mediaModifiedAt: row.mediaModifiedAt, + extractedAt: row.extractedAt, + }; +} + +export class LanceDbCcipVectorStore implements ICcipVectorStore { + private connectionPromise: Promise | null = null; + private tablePromise: Promise | null = null; + private writeQueue: Promise = Promise.resolve(); + + constructor(private readonly directory: string) {} + + private async connection(): Promise { + if (!this.connectionPromise) { + this.connectionPromise = import("@lancedb/lancedb").then((lancedb) => + lancedb.connect(path.resolve(process.cwd(), this.directory)), + ); + } + return await this.connectionPromise; + } + + private async table(): Promise
{ + if (!this.tablePromise) { + this.tablePromise = this.openOrCreateTable(); + } + return await this.tablePromise; + } + + private async openOrCreateTable(): Promise
{ + const db = await this.connection(); + try { + return await db.openTable(TABLE_NAME); + } catch { + const arrow = await import("apache-arrow"); + const schema = new arrow.Schema([ + new arrow.Field("mediaId", new arrow.Utf8(), false), + new arrow.Field("mediaSourceId", new arrow.Utf8(), false), + new arrow.Field( + "vector", + new arrow.FixedSizeList( + VECTOR_DIMENSIONS, + new arrow.Field("item", new arrow.Float32(), false), + ), + false, + ), + new arrow.Field("model", new arrow.Utf8(), false), + new arrow.Field("embeddingVersion", new arrow.Int32(), false), + new arrow.Field( + "mediaModifiedAt", + new arrow.TimestampMillisecond(), + false, + ), + new arrow.Field("extractedAt", new arrow.TimestampMillisecond(), false), + ]); + return await db.createTable(TABLE_NAME, [], { schema }); + } + } + + private async serializeWrite(operation: () => Promise): Promise { + const next = this.writeQueue.then(operation, operation); + this.writeQueue = next.catch(() => undefined); + await next; + } + + async get(mediaId: string): Promise { + const table = await this.table(); + const rows = await table + .query() + .where(`mediaId = '${escapeSqlString(mediaId)}'`) + .limit(1) + .toArray(); + return rows[0] ? toRecord(rows[0]) : null; + } + + async upsert(record: CcipVectorRecord): Promise { + await this.serializeWrite(async () => { + const table = await this.table(); + await table.delete(`mediaId = '${escapeSqlString(record.mediaId)}'`); + await table.add([ + { + ...record, + mediaModifiedAt: record.mediaModifiedAt, + extractedAt: record.extractedAt, + }, + ]); + }); + } + + async delete(mediaId: string): Promise { + await this.serializeWrite(async () => { + const table = await this.table(); + await table.delete(`mediaId = '${escapeSqlString(mediaId)}'`); + }); + } + + async deleteBySource(mediaSourceId: string): Promise { + await this.serializeWrite(async () => { + const table = await this.table(); + await table.delete(`mediaSourceId = '${escapeSqlString(mediaSourceId)}'`); + }); + } + + async listMediaIds(mediaSourceId?: string): Promise { + const table = await this.table(); + const query = table.query().select(["mediaId"]); + if (mediaSourceId) { + query.where(`mediaSourceId = '${escapeSqlString(mediaSourceId)}'`); + } + const rows = await query.toArray(); + return rows.flatMap((row) => { + const result = z.object({ mediaId: z.string().uuid() }).safeParse(row); + return result.success ? [result.data.mediaId] : []; + }); + } + + async list(mediaSourceId?: string): Promise { + const table = await this.table(); + const query = table.query(); + if (mediaSourceId) { + query.where(`mediaSourceId = '${escapeSqlString(mediaSourceId)}'`); + } + const rows = await query.toArray(); + return rows.map(toRecord); + } + + async search( + vector: number[], + limit: number, + mediaSourceId?: string, + ): Promise { + const table = await this.table(); + const query = table + .vectorSearch(vector) + .distanceType("cosine") + .limit(limit); + if (mediaSourceId) { + query.where(`mediaSourceId = '${escapeSqlString(mediaSourceId)}'`); + } + const rows = await query.toArray(); + return rows.map((value) => { + const row = rowSchema.parse(value); + return { + ...toRecord(value), + cosineDistance: row._distance ?? 0, + }; + }); + } +} diff --git a/apps/server/src/infrastructure/ai/rust-ai-client.ts b/apps/server/src/infrastructure/ai/rust-ai-client.ts index f2373fc1..2ff25ae8 100644 --- a/apps/server/src/infrastructure/ai/rust-ai-client.ts +++ b/apps/server/src/infrastructure/ai/rust-ai-client.ts @@ -25,6 +25,21 @@ function createRemoteOrpcClient(remoteUrl: string, timeoutMs: number) { }); } +function hasCcipDistances(value: unknown): value is { + ccipDistances( + feature: number[], + candidates: number[][], + modelName?: string, + ): number[] | Promise; +} { + return ( + typeof value === "object" && + value !== null && + "ccipDistances" in value && + typeof value.ccipDistances === "function" + ); +} + export class RustAiClient implements IAiClient { private baseUrl: string; private timeoutMs: number; @@ -241,4 +256,22 @@ export class RustAiClient implements IAiClient { difference: distance, }); } + + async calculateCcipDistances( + feature: number[], + candidates: number[][], + ): Promise { + if (!this.baseUrl) { + const nativeModule: unknown = await import("dghs-imgutils-rs"); + if (hasCcipDistances(nativeModule)) { + return await nativeModule.ccipDistances(feature, candidates); + } + } + return await Promise.all( + candidates.map(async (candidate) => { + const result = await this.calculateCcipDifference(feature, candidate); + return result.difference; + }), + ); + } } diff --git a/apps/server/src/infrastructure/api-clients/ai-api.ts b/apps/server/src/infrastructure/api-clients/ai-api.ts index ab4532e6..29045f94 100644 --- a/apps/server/src/infrastructure/api-clients/ai-api.ts +++ b/apps/server/src/infrastructure/api-clients/ai-api.ts @@ -43,3 +43,30 @@ export function startBatchTaggingWithIds(params: { }) { return orpc.ai.startBatchTaggingWithIds(params); } + +export function getCcipVectorStatus(mediaSourceId: string, mediaId: string) { + return orpc.ai.ccipVectorStatus({ mediaSourceId, mediaId }); +} + +export function startCcipExtraction( + mediaSourceId: string, + mediaId: string, + force = false, +) { + return orpc.ai.startCcipExtraction({ mediaSourceId, mediaId, force }); +} + +export function scanBatchCcipTargets(params: { + force?: boolean; + mediaSourceId?: string; +}) { + return orpc.ai.scanBatchCcipTargets(params); +} + +export function startBatchCcipExtraction(params: { + force?: boolean; + mediaSourceId?: string; + mediaIds: string[]; +}) { + return orpc.ai.startBatchCcipExtraction(params); +} diff --git a/apps/server/src/infrastructure/api-clients/search-api.ts b/apps/server/src/infrastructure/api-clients/search-api.ts index 8ca56929..bc45e27d 100644 --- a/apps/server/src/infrastructure/api-clients/search-api.ts +++ b/apps/server/src/infrastructure/api-clients/search-api.ts @@ -23,3 +23,11 @@ export function searchMedia( params, }); } + +export function searchSimilar(input: { + anchorMediaId: string; + mediaSourceId?: string; + topK: number; +}) { + return orpc.media.searchSimilar(input); +} diff --git a/apps/server/src/infrastructure/api/routers/ai-router.ts b/apps/server/src/infrastructure/api/routers/ai-router.ts index c40d70d1..68f91419 100644 --- a/apps/server/src/infrastructure/api/routers/ai-router.ts +++ b/apps/server/src/infrastructure/api/routers/ai-router.ts @@ -1,6 +1,10 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { ORPCError, os } from "@orpc/server"; +import { + CCIP_EMBEDDING_VERSION, + CCIP_MODEL, +} from "@solid-imager/application/services/ccip-vector-service"; import { createClient } from "@solid-imager/client"; import { type Media, @@ -8,22 +12,38 @@ import { } from "@solid-imager/core/domain/media/schemas"; import type { NapiBBox } from "@solid-imager/core/domain/tagging/schemas"; import { + batchCcipExtractionRequestSchema, batchTaggingRequestSchema, ccipDifferenceRequestSchema, + ccipExtractionRequestSchema, ccipFeatureRequestSchema, + ccipVectorStatusSchema, startBatchTaggingResponseSchema, + startCcipExtractionResponseSchema, tagImageRequestSchema, } from "@solid-imager/core/domain/tagging/schemas"; -import { and, asc, eq, getTableColumns, inArray, isNull } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + getTableColumns, + inArray, + isNull, + sql, +} from "drizzle-orm"; import sharp from "sharp"; import { z } from "zod"; import { services } from "~/application/registry"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { taggingService } from "~/application/services/tagging-service"; import type { appRouter } from "~/domain/shared/api-contract"; import { db } from "~/infrastructure/db"; import { + jobs, mediaCharacters, mediaIps, + mediaSources, medias, mediaTags, } from "~/infrastructure/db/schema"; @@ -321,6 +341,7 @@ export const aiRouter = { const parentJob = await jobRepo.create({ type: "bulk_tagging_parent", + status: "in_progress", mediaSourceId, payload: { total: mediaIds.length, @@ -363,6 +384,136 @@ export const aiRouter = { }; }), + ccipVectorStatus: os + .input( + ccipExtractionRequestSchema.pick({ mediaSourceId: true, mediaId: true }), + ) + .output(ccipVectorStatusSchema) + .handler(async ({ input }) => { + const latestJob = await db.query.jobs.findFirst({ + where: and( + eq(jobs.type, "extract_ccip_vector"), + eq(jobs.mediaSourceId, input.mediaSourceId), + sql`${jobs.payload}->>'mediaId' = ${input.mediaId}`, + ), + orderBy: desc(jobs.createdAt), + }); + if ( + latestJob?.status === "pending" || + latestJob?.status === "in_progress" + ) { + return { status: "processing" as const }; + } + const status = await ccipVectorService.getStatus( + input.mediaSourceId, + input.mediaId, + ); + if (latestJob?.status === "failed" && status.status !== "ready") { + return { + status: "failed" as const, + error: latestJob.error ?? "CCIP vector extraction failed", + }; + } + return status; + }), + + startCcipExtraction: os + .input(ccipExtractionRequestSchema) + .output(startCcipExtractionResponseSchema) + .handler(async ({ input }) => { + const job = await services.getJobRepository().create({ + type: "extract_ccip_vector", + mediaSourceId: input.mediaSourceId, + payload: { mediaId: input.mediaId, force: input.force }, + }); + return { + success: true, + message: "CCIP vector extraction queued", + jobId: job.id, + }; + }), + + scanBatchCcipTargets: os + .input(batchCcipExtractionRequestSchema) + .output(z.array(mediaSchema)) + .handler(async ({ input }) => { + const rows = await db + .select(getTableColumns(medias)) + .from(medias) + .innerJoin(mediaSources, eq(mediaSources.id, medias.mediaSourceId)) + .where( + and( + eq(medias.mediaType, "image"), + eq(mediaSources.type, "local"), + input.mediaSourceId + ? eq(medias.mediaSourceId, input.mediaSourceId) + : undefined, + ), + ) + .orderBy(asc(medias.id)); + if (input.force) return rows.map((row) => mediaSchema.parse(row)); + const records = new Map( + (await ccipVectorService.listRecords(input.mediaSourceId)).map( + (record) => [record.mediaId, record], + ), + ); + return rows + .filter((row) => { + const record = records.get(row.id); + return ( + !record || + record.model !== CCIP_MODEL || + record.embeddingVersion !== CCIP_EMBEDDING_VERSION || + record.mediaModifiedAt.getTime() !== row.modifiedAt.getTime() + ); + }) + .map((row) => mediaSchema.parse(row)); + }), + + startBatchCcipExtraction: os + .input( + batchCcipExtractionRequestSchema.extend({ + mediaIds: z.array(z.string().uuid()).min(1), + }), + ) + .output(startCcipExtractionResponseSchema) + .handler(async ({ input }) => { + const mediaItems = await db.query.medias.findMany({ + where: and( + inArray(medias.id, input.mediaIds), + eq(medias.mediaType, "image"), + ), + columns: { id: true, mediaSourceId: true }, + }); + if (mediaItems.length === 0) { + throw new ORPCError("BAD_REQUEST", { + message: "No valid images selected", + }); + } + const jobRepository = services.getJobRepository(); + const parent = await jobRepository.create({ + type: "batch_ccip_parent", + status: "in_progress", + mediaSourceId: input.mediaSourceId, + payload: { total: mediaItems.length, processed: 0 }, + }); + await Promise.all( + mediaItems.map((media) => + jobRepository.create({ + type: "extract_ccip_vector", + mediaSourceId: media.mediaSourceId, + parentId: parent.id, + payload: { mediaId: media.id, force: input.force }, + }), + ), + ); + return { + success: true, + message: "Batch CCIP vector extraction started", + jobId: parent.id, + }; + }), + detectAndCropCharacters: os .input( z.union([ diff --git a/apps/server/src/infrastructure/api/routers/media-router.ts b/apps/server/src/infrastructure/api/routers/media-router.ts index 664e22b3..72063b30 100644 --- a/apps/server/src/infrastructure/api/routers/media-router.ts +++ b/apps/server/src/infrastructure/api/routers/media-router.ts @@ -9,11 +9,14 @@ import { bulkTagMediaRequestSchema, findDuplicatesRequestSchema, mediaSearchRequestSchema, + similarMediaSearchResponseSchema, updateMediaRequestSchema, } from "@solid-imager/core/domain/media/schemas"; +import { similarMediaRequestSchema } from "@solid-imager/core/domain/tagging/schemas"; import { asyncPool } from "@solid-imager/core/utils/async-pool"; import { z } from "zod"; import { BulkOperationService } from "~/application/services/bulk-operation-service"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { MediaService } from "~/application/services/media-service"; /** @@ -35,6 +38,17 @@ export const mediaRouter = { await MediaService.searchMedia(input.sourceId, input.params), ), + searchSimilar: os + .input(similarMediaRequestSchema) + .output(similarMediaSearchResponseSchema) + .handler(async ({ input }) => { + return await ccipVectorService.searchSimilar( + input.anchorMediaId, + input.topK, + input.mediaSourceId, + ); + }), + /** * Get a specific media file */ @@ -186,6 +200,7 @@ export const mediaRouter = { ) .handler(async ({ input }) => { await MediaService.deleteMedia(input.sourceId, input.mediaId); + await ccipVectorService.delete(input.mediaId); return { success: true }; }), @@ -270,6 +285,9 @@ export const mediaRouter = { input.mediaSourceId, input.mediaIds, ); + await Promise.all( + input.mediaIds.map((mediaId) => ccipVectorService.delete(mediaId)), + ); return { success: true }; }), diff --git a/apps/server/src/infrastructure/bootstrap.ts b/apps/server/src/infrastructure/bootstrap.ts index 37090eba..6d4b9c78 100644 --- a/apps/server/src/infrastructure/bootstrap.ts +++ b/apps/server/src/infrastructure/bootstrap.ts @@ -105,6 +105,7 @@ export function initServices() { mediaStorage: services.getMediaStorage(), logger, enableAutoTagging: config.jobs.enableAutoTagging, + enableAutoCcipExtraction: config.jobs.enableAutoCcipExtraction, supportedExtensions: config.media.supportedExtensions, generateThumbnail: ( media: { id: string; filePath: string }, @@ -118,6 +119,7 @@ export function initServices() { configService.onChange((newConfig) => mediaProcessingService.updateConfig({ enableAutoTagging: newConfig.jobs.enableAutoTagging, + enableAutoCcipExtraction: newConfig.jobs.enableAutoCcipExtraction, }), ); } diff --git a/apps/server/src/infrastructure/jobs/ccip-jobs.ts b/apps/server/src/infrastructure/jobs/ccip-jobs.ts new file mode 100644 index 00000000..a175454a --- /dev/null +++ b/apps/server/src/infrastructure/jobs/ccip-jobs.ts @@ -0,0 +1,68 @@ +import { getErrorMessage } from "@solid-imager/core/utils"; +import { z } from "zod"; +import { services } from "~/application/registry"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; +import type { Job } from "~/infrastructure/db/schema"; +import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; +import { logger } from "~/infrastructure/logger"; + +const payloadSchema = z.object({ + mediaId: z.string().uuid(), + force: z.boolean().default(false), +}); + +const parentPayloadSchema = z.object({ + total: z.number().int().nonnegative(), + processed: z.number().int().nonnegative(), +}); + +async function updateParent(job: Job): Promise { + if (!job.parentId) return; + const jobRepository = services.getJobRepository(); + await jobRepository.incrementProgress(job.parentId); + const parent = await jobRepository.findById(job.parentId); + if (!parent) return; + const payload = parentPayloadSchema.parse(parent.payload); + RealtimeEventBus.publishJob("job-progress", { + jobId: parent.id, + processed: payload.processed, + total: payload.total, + }); + if (payload.processed >= payload.total) { + await jobRepository.markAsCompleted(parent.id, { success: true }); + RealtimeEventBus.publishJob("job-completed", { + jobId: parent.id, + message: "CCIP vector extraction completed", + }); + } +} + +export async function processCcipExtractionJob(job: Job): Promise { + const payload = payloadSchema.parse(job.payload); + if (!job.mediaSourceId) { + throw new Error("CCIP extraction job is missing mediaSourceId"); + } + try { + await ccipVectorService.extract( + job.mediaSourceId, + payload.mediaId, + payload.force, + ); + await updateParent(job); + } catch (error) { + logger.error( + { err: error, mediaId: payload.mediaId }, + "CCIP vector extraction failed", + ); + if (job.parentId) { + await services + .getJobRepository() + .markAsFailed(job.parentId, getErrorMessage(error)); + RealtimeEventBus.publishJob("job-failed", { + jobId: job.parentId, + error: getErrorMessage(error), + }); + } + throw error; + } +} diff --git a/apps/server/src/infrastructure/jobs/file-watcher-service.ts b/apps/server/src/infrastructure/jobs/file-watcher-service.ts index 8132b2de..f66f0877 100644 --- a/apps/server/src/infrastructure/jobs/file-watcher-service.ts +++ b/apps/server/src/infrastructure/jobs/file-watcher-service.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { services } from "~/application/registry"; +import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { DirectorySyncService } from "~/application/services/directory-sync-service"; import { MediaProcessingService } from "~/application/services/media-processing-service"; import { RealtimeEventBus } from "~/infrastructure/events/realtime-event-bus"; @@ -84,6 +85,14 @@ async function handleFileDeleted( // Delete from database await MediaRepository.delete(media.id); + try { + await ccipVectorService.delete(media.id); + } catch (error) { + logger.warn( + { err: error, mediaId: media.id }, + "Failed to delete CCIP vector for removed media", + ); + } // Delete thumbnail await deleteThumbnail(mediaSourceId, media.id); diff --git a/apps/server/src/infrastructure/jobs/job-worker.ts b/apps/server/src/infrastructure/jobs/job-worker.ts index a329069d..e2984366 100644 --- a/apps/server/src/infrastructure/jobs/job-worker.ts +++ b/apps/server/src/infrastructure/jobs/job-worker.ts @@ -19,7 +19,10 @@ export class JobWorker { private readonly jobRepo: IJobRepository; private readonly processor: (job: Job) => Promise; - private readonly aiJobTypes = new Set(["auto_tagging"]); + private readonly aiJobTypes = new Set([ + "auto_tagging", + "extract_ccip_vector", + ]); constructor(jobRepo: IJobRepository, processor: (job: Job) => Promise) { this.jobRepo = jobRepo; diff --git a/apps/server/src/routes/manager.tsx b/apps/server/src/routes/manager.tsx index 46fc971b..cb2e5ca9 100644 --- a/apps/server/src/routes/manager.tsx +++ b/apps/server/src/routes/manager.tsx @@ -9,7 +9,9 @@ import { createFileRoute } from "@tanstack/solid-router"; import { MediaCardItem } from "~/components/media/media-card-item"; import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; import { + scanBatchCcipTargets, scanBatchTaggingTargets, + startBatchCcipExtraction, startBatchTaggingWithIds, } from "~/infrastructure/api-clients/ai-api"; import { @@ -57,6 +59,8 @@ const managerActions = { deleteCharacter, scanBatchTaggingTargets, startBatchTaggingWithIds, + scanBatchCcipTargets, + startBatchCcipExtraction, findDuplicateMedia, bulkDeleteMedia, }; diff --git a/apps/server/src/routes/search.tsx b/apps/server/src/routes/search.tsx index 8fbb7fe4..7770adf3 100644 --- a/apps/server/src/routes/search.tsx +++ b/apps/server/src/routes/search.tsx @@ -17,7 +17,10 @@ import { mediaSourcesQueryOptions, tagsQueryOptions, } from "~/infrastructure/api-clients/queries"; -import { searchMedia } from "~/infrastructure/api-clients/search-api"; +import { + searchMedia, + searchSimilar, +} from "~/infrastructure/api-clients/search-api"; import { getSearchCondition, searchState, @@ -49,6 +52,7 @@ function SearchRoute() { const page = useSearchPage({ searchMedia, + searchSimilar, queryClient, queries: { tags: tagsQueryOptions, @@ -66,6 +70,9 @@ function SearchRoute() { scrollY: () => searchState.scrollY, setScrollY: (y) => setSearchState("scrollY", y), setOffset: (o) => setSearchState("offset", o), + mode: () => searchState.mode, + similarityAnchorMediaId: () => searchState.similarityAnchorMediaId, + similarityTopK: () => searchState.similarityTopK, refreshDebounceMs: SEARCH_RESULTS_REFRESH_DEBOUNCE_MS, }); diff --git a/apps/server/src/tests/unit/application/services/ccip-vector-service.test.ts b/apps/server/src/tests/unit/application/services/ccip-vector-service.test.ts new file mode 100644 index 00000000..886e350a --- /dev/null +++ b/apps/server/src/tests/unit/application/services/ccip-vector-service.test.ts @@ -0,0 +1,96 @@ +import { CcipVectorService } from "@solid-imager/application/services/ccip-vector-service"; +import { describe, expect, it, vi } from "vitest"; + +const source = { id: "00000000-0000-4000-8000-000000000010", type: "local" }; +const media = { + id: "00000000-0000-4000-8000-000000000001", + mediaSourceId: source.id, + mediaType: "image", + modifiedAt: new Date("2026-01-01T00:00:00Z"), +}; + +describe("CcipVectorService", () => { + it("skips extraction when the stored vector is current", async () => { + const record = { + mediaId: media.id, + mediaSourceId: source.id, + vector: new Array(768).fill(0), + model: "ccip-caformer-24-randaug-pruned", + embeddingVersion: 1, + mediaModifiedAt: media.modifiedAt, + extractedAt: new Date(), + }; + const taggingService = { getCcipFeatureForMedia: vi.fn() }; + const service = new CcipVectorService({ + mediaRepository: { + findById: vi.fn().mockResolvedValue(media), + } as any, + sourceRepository: { + findById: vi.fn().mockResolvedValue(source), + } as any, + taggingService: taggingService as any, + vectorStore: { + get: vi.fn().mockResolvedValue(record), + } as any, + }); + + const result = await service.extract(source.id, media.id); + + expect(result.skipped).toBe(true); + expect(taggingService.getCcipFeatureForMedia).not.toHaveBeenCalled(); + }); + + it("reranks LanceDB candidates using CCIP distance", async () => { + const anchorVector = new Array(768).fill(0); + const candidateA = { + ...media, + id: "00000000-0000-4000-8000-000000000002", + }; + const candidateB = { + ...media, + id: "00000000-0000-4000-8000-000000000003", + }; + const record = (item: typeof media, vector: number[]) => ({ + mediaId: item.id, + mediaSourceId: source.id, + vector, + model: "ccip-caformer-24-randaug-pruned", + embeddingVersion: 1, + mediaModifiedAt: item.modifiedAt, + extractedAt: new Date(), + }); + const service = new CcipVectorService({ + mediaRepository: { + findById: vi.fn().mockResolvedValue(media), + findByIds: vi.fn().mockResolvedValue([candidateA, candidateB]), + } as any, + sourceRepository: { + findById: vi.fn().mockResolvedValue(source), + } as any, + taggingService: { + getCcipDistances: vi.fn().mockResolvedValue([0.4, 0.1]), + } as any, + vectorStore: { + get: vi.fn().mockResolvedValue(record(media, anchorVector)), + search: vi.fn().mockResolvedValue([ + { + ...record(candidateA, new Array(768).fill(1)), + cosineDistance: 0.1, + }, + { + ...record(candidateB, new Array(768).fill(2)), + cosineDistance: 0.2, + }, + ]), + } as any, + }); + + const result = await service.searchSimilar(media.id, 2); + + expect(result.media.map((item) => item.id)).toEqual([ + candidateB.id, + candidateA.id, + ]); + expect(result.scores.map((item) => item.ccipDistance)).toEqual([0.1, 0.4]); + }); +}); diff --git a/apps/server/src/tests/unit/application/services/directory-sync-service.test.ts b/apps/server/src/tests/unit/application/services/directory-sync-service.test.ts index 9b94c6d7..dce05953 100644 --- a/apps/server/src/tests/unit/application/services/directory-sync-service.test.ts +++ b/apps/server/src/tests/unit/application/services/directory-sync-service.test.ts @@ -5,6 +5,7 @@ vi.mock("~/infrastructure/logger", () => ({ logger: { info: vi.fn(), error: vi.fn(), + warn: vi.fn(), debug: vi.fn(), }, })); diff --git a/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts b/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts index 7733f9ea..6fa55e89 100644 --- a/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts +++ b/apps/server/src/tests/unit/infrastructure/jobs/job-worker.test.ts @@ -94,7 +94,9 @@ describe("JobWorker", () => { // Should fetch 2 normal jobs expect(jobRepo.claimPending).toHaveBeenCalledWith( 2, - expect.objectContaining({ excludeTypes: ["auto_tagging"] }), + expect.objectContaining({ + excludeTypes: ["auto_tagging", "extract_ccip_vector"], + }), ); expect(processor).toHaveBeenCalledTimes(2); }); @@ -132,7 +134,9 @@ describe("JobWorker", () => { // Should fetch 1 AI job expect(jobRepo.claimPending).toHaveBeenCalledWith( 1, - expect.objectContaining({ includeTypes: ["auto_tagging"] }), + expect.objectContaining({ + includeTypes: ["auto_tagging", "extract_ccip_vector"], + }), ); expect(processor).toHaveBeenCalledTimes(1); expect(processor).toHaveBeenCalledWith( @@ -183,11 +187,15 @@ describe("JobWorker", () => { // Should fetch 1 AI job and 2 Normal jobs expect(jobRepo.claimPending).toHaveBeenCalledWith( 1, - expect.objectContaining({ includeTypes: ["auto_tagging"] }), + expect.objectContaining({ + includeTypes: ["auto_tagging", "extract_ccip_vector"], + }), ); expect(jobRepo.claimPending).toHaveBeenCalledWith( 2, - expect.objectContaining({ excludeTypes: ["auto_tagging"] }), + expect.objectContaining({ + excludeTypes: ["auto_tagging", "extract_ccip_vector"], + }), ); expect(processor).toHaveBeenCalledTimes(TotalExpectedCalls); diff --git a/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx b/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx index 8c44dce7..e8dd47ff 100644 --- a/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx +++ b/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx @@ -1,5 +1,7 @@ import type { MediaDetails } from "@solid-imager/core/domain/media/schemas"; import { MediaSidebarContent } from "@solid-imager/ui/media-sidebar-content"; +import { activateVectorSearch } from "@solid-imager/ui/stores/search-store"; +import { useNavigate } from "@tanstack/solid-router"; import { addCharacterToMedia, createCharacter, @@ -18,6 +20,7 @@ import { } from "~/infrastructure/api-clients/projects-api"; import { buildMediaContentUrl } from "~/infrastructure/media/thumbnail-runtime"; import { getApiFetch } from "~/infrastructure/tauri-fetch-helpers"; +import { client } from "~/orpc-client"; import { allCharactersQueryOptions, allIpsQueryOptions, @@ -35,6 +38,7 @@ type MediaSidebarProps = { }; export function MediaSidebar(props: MediaSidebarProps) { + const navigate = useNavigate(); const loadMediaFile = async () => { const url = buildMediaContentUrl(props.media.mediaSourceId, props.media.id); const response = await getApiFetch()(url); @@ -78,6 +82,23 @@ export function MediaSidebar(props: MediaSidebarProps) { onClose={modalProps.onClose} /> )} + getCcipVectorStatus={() => + client.ai.ccipVectorStatus({ + mediaSourceId: props.media.mediaSourceId, + mediaId: props.media.id, + }) + } + startCcipExtraction={(force) => + client.ai.startCcipExtraction({ + mediaSourceId: props.media.mediaSourceId, + mediaId: props.media.id, + force, + }) + } + onFindSimilar={() => { + activateVectorSearch(props.media.id); + void navigate({ to: "/search" }); + }} updateMediaDescription={(mediaSourceId, mediaId, description) => updateMedia(mediaSourceId, mediaId, { description }) } diff --git a/apps/tauri/src/infrastructure/api-clients/search-api.ts b/apps/tauri/src/infrastructure/api-clients/search-api.ts index bc670b57..93aefe8a 100644 --- a/apps/tauri/src/infrastructure/api-clients/search-api.ts +++ b/apps/tauri/src/infrastructure/api-clients/search-api.ts @@ -12,3 +12,11 @@ export function searchMedia( ) { return client.media.search({ sourceId, params }); } + +export function searchSimilar(input: { + anchorMediaId: string; + mediaSourceId?: string; + topK: number; +}) { + return client.media.searchSimilar(input); +} diff --git a/apps/tauri/src/routes/manager.tsx b/apps/tauri/src/routes/manager.tsx index 59aad1c9..de1fb024 100644 --- a/apps/tauri/src/routes/manager.tsx +++ b/apps/tauri/src/routes/manager.tsx @@ -31,6 +31,7 @@ import { deleteProject, updateProject, } from "~/infrastructure/api-clients/projects-api"; +import { client } from "~/orpc-client"; import { allCharactersQueryOptions, allIpsQueryOptions, @@ -57,6 +58,13 @@ const managerActions = { deleteCharacter, scanBatchTaggingTargets, startBatchTaggingWithIds, + scanBatchCcipTargets: (input: { force: boolean; mediaSourceId?: string }) => + client.ai.scanBatchCcipTargets(input), + startBatchCcipExtraction: (input: { + force: boolean; + mediaSourceId?: string; + mediaIds: string[]; + }) => client.ai.startBatchCcipExtraction(input), findDuplicateMedia, bulkDeleteMedia, }; diff --git a/apps/tauri/src/routes/search.tsx b/apps/tauri/src/routes/search.tsx index 81792a6f..18feb2ee 100644 --- a/apps/tauri/src/routes/search.tsx +++ b/apps/tauri/src/routes/search.tsx @@ -8,7 +8,10 @@ import { MediaGridItem } from "~/components/media/media-grid-item"; import { useCurrentSearchPersistence } from "~/hooks/use-current-search-persistence"; import { useMediaSourceEvents } from "~/hooks/use-media-source-events"; import { PresetClient as rawPresetClient } from "~/infrastructure/api/clients/preset-client"; -import { searchMedia } from "~/infrastructure/api-clients/search-api"; +import { + searchMedia, + searchSimilar, +} from "~/infrastructure/api-clients/search-api"; import { getSearchCondition, searchState, @@ -48,6 +51,7 @@ function SearchRoute() { const page = useSearchPage({ searchMedia, + searchSimilar, queryClient, queries: { tags: tagsQueryOptions, @@ -65,6 +69,9 @@ function SearchRoute() { scrollY: () => searchState.scrollY, setScrollY: (y) => setSearchState("scrollY", y), setOffset: (o) => setSearchState("offset", o), + mode: () => searchState.mode, + similarityAnchorMediaId: () => searchState.similarityAnchorMediaId, + similarityTopK: () => searchState.similarityTopK, refreshDebounceMs: SEARCH_RESULTS_REFRESH_DEBOUNCE_MS, }); diff --git a/packages/application/src/ports/ccip-vector-store.ts b/packages/application/src/ports/ccip-vector-store.ts new file mode 100644 index 00000000..07a2309f --- /dev/null +++ b/packages/application/src/ports/ccip-vector-store.ts @@ -0,0 +1,27 @@ +export type CcipVectorRecord = { + mediaId: string; + mediaSourceId: string; + vector: number[]; + model: string; + embeddingVersion: number; + mediaModifiedAt: Date; + extractedAt: Date; +}; + +export type CcipVectorCandidate = CcipVectorRecord & { + cosineDistance: number; +}; + +export interface ICcipVectorStore { + get(mediaId: string): Promise; + upsert(record: CcipVectorRecord): Promise; + delete(mediaId: string): Promise; + deleteBySource(mediaSourceId: string): Promise; + listMediaIds(mediaSourceId?: string): Promise; + list(mediaSourceId?: string): Promise; + search( + vector: number[], + limit: number, + mediaSourceId?: string, + ): Promise; +} diff --git a/packages/application/src/ports/index.ts b/packages/application/src/ports/index.ts index 07ac5367..17f73a52 100644 --- a/packages/application/src/ports/index.ts +++ b/packages/application/src/ports/index.ts @@ -1,5 +1,10 @@ export type { IAuthorService } from "./author-service"; export type { ICategoryService } from "./category-service"; +export type { + CcipVectorCandidate, + CcipVectorRecord, + ICcipVectorStore, +} from "./ccip-vector-store"; export type { ICharacterService } from "./character-service"; export type { ICollectionService } from "./collection-service"; export type { IIpService } from "./ip-service"; diff --git a/packages/application/src/ports/media-processing-service.ts b/packages/application/src/ports/media-processing-service.ts index 5ee5e02e..dc6427a8 100644 --- a/packages/application/src/ports/media-processing-service.ts +++ b/packages/application/src/ports/media-processing-service.ts @@ -20,5 +20,8 @@ export interface IMediaProcessingService { tx?: Transaction, ): Promise; - updateConfig(config: { enableAutoTagging: boolean }): void; + updateConfig(config: { + enableAutoTagging: boolean; + enableAutoCcipExtraction: boolean; + }): void; } diff --git a/packages/application/src/ports/tagging-service.ts b/packages/application/src/ports/tagging-service.ts index 3a5cd46b..ad6989f5 100644 --- a/packages/application/src/ports/tagging-service.ts +++ b/packages/application/src/ports/tagging-service.ts @@ -17,4 +17,8 @@ export interface ITaggingService { mediaId: string, ): Promise; getCcipDifference(feature1: number[], feature2: number[]): Promise; + getCcipDistances( + feature: number[], + candidates: number[][], + ): Promise; } diff --git a/packages/application/src/services/ccip-vector-service.ts b/packages/application/src/services/ccip-vector-service.ts new file mode 100644 index 00000000..945aa087 --- /dev/null +++ b/packages/application/src/services/ccip-vector-service.ts @@ -0,0 +1,179 @@ +import type { + Media, + SimilarMediaSearchResponse, +} from "@solid-imager/core/domain/media/schemas"; +import type { IMediaRepository } from "@solid-imager/core/domain/repositories/media-repository"; +import type { SourceRepository } from "@solid-imager/core/domain/repositories/source-repository"; +import type { + CcipVectorRecord, + ICcipVectorStore, +} from "../ports/ccip-vector-store"; +import type { ITaggingService } from "../ports/tagging-service"; + +export const CCIP_MODEL = "ccip-caformer-24-randaug-pruned"; +export const CCIP_EMBEDDING_VERSION = 1; +const MIN_CANDIDATES = 100; +const CANDIDATE_MULTIPLIER = 5; +const MAX_CANDIDATES = 1000; + +export type CcipVectorServiceDeps = { + mediaRepository: IMediaRepository; + sourceRepository: SourceRepository; + taggingService: ITaggingService; + vectorStore: ICcipVectorStore; +}; + +export class CcipVectorService { + constructor(private readonly deps: CcipVectorServiceDeps) {} + + async extract( + mediaSourceId: string, + mediaId: string, + force = false, + ): Promise<{ record: CcipVectorRecord; skipped: boolean }> { + const media = await this.requireImage(mediaSourceId, mediaId); + const existing = await this.deps.vectorStore.get(mediaId); + if (!force && existing && this.isCurrent(existing, media)) { + return { record: existing, skipped: true }; + } + + const result = await this.deps.taggingService.getCcipFeatureForMedia( + mediaSourceId, + mediaId, + ); + const record: CcipVectorRecord = { + mediaId, + mediaSourceId, + vector: result.feature, + model: CCIP_MODEL, + embeddingVersion: CCIP_EMBEDDING_VERSION, + mediaModifiedAt: media.modifiedAt, + extractedAt: new Date(), + }; + await this.deps.vectorStore.upsert(record); + return { record, skipped: false }; + } + + async getStatus( + mediaSourceId: string, + mediaId: string, + ): Promise<{ + status: "missing" | "ready" | "stale"; + model?: string; + extractedAt?: Date; + }> { + const media = await this.requireImage(mediaSourceId, mediaId); + const record = await this.deps.vectorStore.get(mediaId); + if (!record) return { status: "missing" }; + return { + status: this.isCurrent(record, media) ? "ready" : "stale", + model: record.model, + extractedAt: record.extractedAt, + }; + } + + async delete(mediaId: string): Promise { + await this.deps.vectorStore.delete(mediaId); + } + + async deleteBySource(mediaSourceId: string): Promise { + await this.deps.vectorStore.deleteBySource(mediaSourceId); + } + + async listExtractedMediaIds(mediaSourceId?: string): Promise { + return await this.deps.vectorStore.listMediaIds(mediaSourceId); + } + + async listRecords(mediaSourceId?: string): Promise { + return await this.deps.vectorStore.list(mediaSourceId); + } + + async searchSimilar( + anchorMediaId: string, + topK: number, + mediaSourceId?: string, + ): Promise { + const anchorMedia = await this.deps.mediaRepository.findById(anchorMediaId); + if (!anchorMedia) throw new Error(`Media not found: ${anchorMediaId}`); + const anchor = await this.deps.vectorStore.get(anchorMediaId); + if (!anchor || !this.isCurrent(anchor, anchorMedia)) { + throw new Error("CCIP vector is missing or stale for the anchor media"); + } + + const candidateLimit = Math.min( + Math.max(topK * CANDIDATE_MULTIPLIER, MIN_CANDIDATES), + MAX_CANDIDATES, + ); + const candidates = ( + await this.deps.vectorStore.search( + anchor.vector, + candidateLimit + 1, + mediaSourceId, + ) + ).filter((candidate) => candidate.mediaId !== anchorMediaId); + const media = await this.deps.mediaRepository.findByIds( + candidates.map((candidate) => candidate.mediaId), + ); + const mediaById = new Map(media.map((item) => [item.id, item])); + const currentCandidates = candidates.filter((candidate) => { + const item = mediaById.get(candidate.mediaId); + return item ? this.isCurrent(candidate, item) : false; + }); + const distances = await this.deps.taggingService.getCcipDistances( + anchor.vector, + currentCandidates.map((candidate) => candidate.vector), + ); + const ranked = currentCandidates + .map((candidate, index) => ({ + candidate, + ccipDistance: distances[index], + })) + .filter( + (item): item is typeof item & { ccipDistance: number } => + item.ccipDistance !== undefined, + ) + .sort((left, right) => left.ccipDistance - right.ccipDistance) + .slice(0, topK); + + const rankedMedia = ranked.flatMap((item) => { + const value = mediaById.get(item.candidate.mediaId); + return value ? [value] : []; + }); + return { + media: rankedMedia, + total: rankedMedia.length, + scores: ranked.map((item) => ({ + mediaId: item.candidate.mediaId, + cosineDistance: item.candidate.cosineDistance, + ccipDistance: item.ccipDistance, + })), + }; + } + + private isCurrent(record: CcipVectorRecord, media: Media): boolean { + return ( + record.model === CCIP_MODEL && + record.embeddingVersion === CCIP_EMBEDDING_VERSION && + record.mediaModifiedAt.getTime() === media.modifiedAt.getTime() + ); + } + + private async requireImage( + mediaSourceId: string, + mediaId: string, + ): Promise { + const media = await this.deps.mediaRepository.findById(mediaId); + if (!media || media.mediaSourceId !== mediaSourceId) { + throw new Error("Media not found in source"); + } + if (media.mediaType !== "image") { + throw new Error("CCIP vector extraction is only supported for images"); + } + const source = await this.deps.sourceRepository.findById(mediaSourceId); + if (!source) throw new Error("Media source not found"); + if (source.type !== "local") { + throw new Error("CCIP vector extraction only supports local sources"); + } + return media; + } +} diff --git a/packages/application/src/services/index.ts b/packages/application/src/services/index.ts index 059abb8a..48618e2b 100644 --- a/packages/application/src/services/index.ts +++ b/packages/application/src/services/index.ts @@ -1,6 +1,11 @@ export type { SearchOptions } from "../ports/search-service"; export { createAuthorService } from "./author-service"; export { createCategoryService } from "./category-service"; +export { + CCIP_EMBEDDING_VERSION, + CCIP_MODEL, + CcipVectorService, +} from "./ccip-vector-service"; export { CharacterServiceImpl } from "./character-service"; export { createCollectionService } from "./collection-service"; export { createIpService } from "./ip-service"; diff --git a/packages/application/src/services/media-processing-service.ts b/packages/application/src/services/media-processing-service.ts index ab3744ce..b8982d43 100644 --- a/packages/application/src/services/media-processing-service.ts +++ b/packages/application/src/services/media-processing-service.ts @@ -37,6 +37,7 @@ export type MediaProcessingServiceDeps = { mediaStorage: IMediaStorage; logger?: ILogger; enableAutoTagging: boolean; + enableAutoCcipExtraction?: boolean; supportedExtensions: { image: string[]; video: string[]; @@ -62,6 +63,7 @@ export class MediaProcessingServiceImpl implements IMediaProcessingService { private readonly imageProcessor: IImageProcessor; private readonly mediaStorage: IMediaStorage; private enableAutoTagging: boolean; + private enableAutoCcipExtraction: boolean; private readonly supportedExtensions: MediaProcessingServiceDeps["supportedExtensions"]; private readonly generateThumbnail: MediaProcessingServiceDeps["generateThumbnail"]; private readonly publishSourceEvent: SourceEventPublisher; @@ -79,14 +81,19 @@ export class MediaProcessingServiceImpl implements IMediaProcessingService { this.imageProcessor = deps.imageProcessor; this.mediaStorage = deps.mediaStorage; this.enableAutoTagging = deps.enableAutoTagging; + this.enableAutoCcipExtraction = deps.enableAutoCcipExtraction ?? false; this.supportedExtensions = deps.supportedExtensions; this.generateThumbnail = deps.generateThumbnail; this.publishSourceEvent = deps.publishSourceEvent; this.logger = deps.logger; } - updateConfig(config: { enableAutoTagging: boolean }): void { + updateConfig(config: { + enableAutoTagging: boolean; + enableAutoCcipExtraction: boolean; + }): void { this.enableAutoTagging = config.enableAutoTagging; + this.enableAutoCcipExtraction = config.enableAutoCcipExtraction; } async registerAndProcess( @@ -262,6 +269,23 @@ export class MediaProcessingServiceImpl implements IMediaProcessingService { } } + if (this.enableAutoCcipExtraction && media.mediaType === "image") { + try { + await this.jobRepo.create({ + type: "extract_ccip_vector", + mediaSourceId, + payload: { + mediaId: media.id, + }, + }); + } catch (e) { + this.logger?.warn( + { err: e, mediaId }, + "Failed to queue CCIP vector extraction job", + ); + } + } + await this.jobRepo.createIfUnique({ type: "sync_lancedb_delta", mediaSourceId, diff --git a/packages/application/src/services/tagging-service.ts b/packages/application/src/services/tagging-service.ts index ab2311f3..bc2fa610 100644 --- a/packages/application/src/services/tagging-service.ts +++ b/packages/application/src/services/tagging-service.ts @@ -361,6 +361,13 @@ export class TaggingServiceImpl implements ITaggingService { return result.difference; } + async getCcipDistances( + feature: number[], + candidates: number[][], + ): Promise { + return await this.aiClient.calculateCcipDistances(feature, candidates); + } + /** * Check if AI service is running on localhost * Path-based API only works when AI service can access the file system diff --git a/packages/core/src/domain/config/config-schema.ts b/packages/core/src/domain/config/config-schema.ts index 8eb0baf1..ae4236cc 100644 --- a/packages/core/src/domain/config/config-schema.ts +++ b/packages/core/src/domain/config/config-schema.ts @@ -5,6 +5,7 @@ const DEFAULT_JOBS_POLL_INTERVAL = 1000; const MIN_JOBS_CONCURRENCY = 1; const MIN_JOBS_POLL_INTERVAL = 100; const DEFAULT_AUTO_TAGGING = false; +const DEFAULT_AUTO_CCIP_EXTRACTION = false; export const JobsConfigSchema = z.object({ concurrency: z @@ -17,6 +18,7 @@ export const JobsConfigSchema = z.object({ .min(MIN_JOBS_POLL_INTERVAL) .default(DEFAULT_JOBS_POLL_INTERVAL), enableAutoTagging: z.boolean().default(DEFAULT_AUTO_TAGGING), + enableAutoCcipExtraction: z.boolean().default(DEFAULT_AUTO_CCIP_EXTRACTION), }); const DEFAULT_JOBS_CONFIG = { @@ -24,6 +26,7 @@ const DEFAULT_JOBS_CONFIG = { aiConcurrency: 1, pollIntervalMs: DEFAULT_JOBS_POLL_INTERVAL, enableAutoTagging: DEFAULT_AUTO_TAGGING, + enableAutoCcipExtraction: DEFAULT_AUTO_CCIP_EXTRACTION, } as const; const DEFAULT_AI_BASE_URL = ""; @@ -151,11 +154,13 @@ const DEFAULT_LOGGING_CONFIG = { export const LanceDbConfigSchema = z.object({ autoFullSync: z.boolean().default(true), cacheDir: z.string().default(".cache/lancedb-cache"), + ccipVectorDir: z.string().default(".cache/lancedb-ccip"), }); const DEFAULT_LANCEDB_CONFIG = { autoFullSync: true, cacheDir: ".cache/lancedb-cache", + ccipVectorDir: ".cache/lancedb-ccip", } as const; export const AppConfigSchema = z.object({ diff --git a/packages/core/src/domain/contract/ai.contract.ts b/packages/core/src/domain/contract/ai.contract.ts index 44ce1add..c9dbc5cb 100644 --- a/packages/core/src/domain/contract/ai.contract.ts +++ b/packages/core/src/domain/contract/ai.contract.ts @@ -2,10 +2,14 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; import { mediaSchema } from "../media/schemas"; import { + batchCcipExtractionRequestSchema, batchTaggingRequestSchema, ccipDifferenceRequestSchema, + ccipExtractionRequestSchema, ccipFeatureRequestSchema, + ccipVectorStatusSchema, detectAndCropResponseSchema, + startCcipExtractionResponseSchema, taggingResponseSchema, tagImageRequestSchema, } from "../tagging/schemas"; @@ -30,6 +34,28 @@ export const aiContract = { ccipDifference: oc.input(ccipDifferenceRequestSchema), + ccipVectorStatus: oc + .input( + ccipExtractionRequestSchema.pick({ mediaSourceId: true, mediaId: true }), + ) + .output(ccipVectorStatusSchema), + + startCcipExtraction: oc + .input(ccipExtractionRequestSchema) + .output(startCcipExtractionResponseSchema), + + scanBatchCcipTargets: oc + .input(batchCcipExtractionRequestSchema) + .output(z.array(mediaSchema)), + + startBatchCcipExtraction: oc + .input( + batchCcipExtractionRequestSchema.extend({ + mediaIds: z.array(z.string().uuid()).min(1), + }), + ) + .output(startCcipExtractionResponseSchema), + scanBatchTaggingTargets: oc .input(batchTaggingRequestSchema) .output(z.array(mediaSchema)), diff --git a/packages/core/src/domain/contract/media.contract.ts b/packages/core/src/domain/contract/media.contract.ts index ee3fb8b5..7bfd94ea 100644 --- a/packages/core/src/domain/contract/media.contract.ts +++ b/packages/core/src/domain/contract/media.contract.ts @@ -8,10 +8,12 @@ import { mediaSchema, mediaSearchRequestSchema, mediaSearchResponseSchema, + similarMediaSearchResponseSchema, tagSchema, updateMediaRequestSchema, } from "../media/schemas"; import { uploadResponseSchema } from "../media/upload-schemas"; +import { similarMediaRequestSchema } from "../tagging/schemas"; export const mediaContract = { search: oc @@ -23,6 +25,10 @@ export const mediaContract = { ) .output(mediaSearchResponseSchema), + searchSimilar: oc + .input(similarMediaRequestSchema) + .output(similarMediaSearchResponseSchema), + get: oc .input( z.object({ diff --git a/packages/core/src/domain/interfaces/ai-client.ts b/packages/core/src/domain/interfaces/ai-client.ts index c76c1cf7..b33d79da 100644 --- a/packages/core/src/domain/interfaces/ai-client.ts +++ b/packages/core/src/domain/interfaces/ai-client.ts @@ -20,5 +20,10 @@ export type IAiClient = { feature2: number[], ): Promise; + calculateCcipDistances( + feature: number[], + candidates: number[][], + ): Promise; + getBaseUrl?: () => string; }; diff --git a/packages/core/src/domain/media/schemas.ts b/packages/core/src/domain/media/schemas.ts index 62f0005f..f303e955 100644 --- a/packages/core/src/domain/media/schemas.ts +++ b/packages/core/src/domain/media/schemas.ts @@ -335,6 +335,22 @@ export const mediaSearchResponseSchema = z.object({ export type MediaSearchResponse = z.infer; +export const similarMediaSearchResponseSchema = z.object({ + media: z.array(mediaSchema), + total: z.number(), + scores: z.array( + z.object({ + mediaId: z.string().uuid(), + cosineDistance: z.number(), + ccipDistance: z.number(), + }), + ), +}); + +export type SimilarMediaSearchResponse = z.infer< + typeof similarMediaSearchResponseSchema +>; + // Combined schema for the details endpoint export const mediaDetailsSchema = mediaSchema.extend({ tags: z.array(tagSchema), diff --git a/packages/core/src/domain/search/logic.ts b/packages/core/src/domain/search/logic.ts index 42589653..c8902cbd 100644 --- a/packages/core/src/domain/search/logic.ts +++ b/packages/core/src/domain/search/logic.ts @@ -10,8 +10,11 @@ import type { SearchState } from "./schema"; */ export const calculateNextModeState = ( currentState: SearchState, - nextMode: "simple" | "pro", + nextMode: "simple" | "pro" | "vector", ): Partial => { + if (nextMode === "vector") { + return { mode: "vector", offset: 0, scrollY: 0 }; + } if (nextMode === "pro") { // Switching from simple to pro: populate advancedCondition from current simple filters const condition = getSearchConditionFromState(currentState); @@ -122,6 +125,7 @@ const applyCriterionToState = ( export const getSearchConditionFromState = ( state: SearchState, ): SearchGroup | undefined => { + if (state.mode === "vector") return; if (state.mode === "pro") { return state.advancedCondition || undefined; } diff --git a/packages/core/src/domain/search/schema.ts b/packages/core/src/domain/search/schema.ts index e75cf238..8f21670a 100644 --- a/packages/core/src/domain/search/schema.ts +++ b/packages/core/src/domain/search/schema.ts @@ -3,7 +3,7 @@ import { searchGroupSchema } from "@/domain/media/schemas"; export const searchStateSchema = z.object({ // Modes - mode: z.enum(["simple", "pro"]), + mode: z.enum(["simple", "pro", "vector"]), activePresetId: z.number().nullable(), // Filters (Simple Mode) @@ -19,6 +19,8 @@ export const searchStateSchema = z.object({ // Filters (Pro Mode) advancedCondition: searchGroupSchema.nullable(), + similarityAnchorMediaId: z.string().uuid().nullable(), + similarityTopK: z.union([z.literal(20), z.literal(50), z.literal(100)]), // Pagination limit: z.number(), @@ -47,6 +49,8 @@ export const defaultState: SearchState = { selectedCharacters: [], selectedAuthors: [], advancedCondition: null, + similarityAnchorMediaId: null, + similarityTopK: 50, limit: 20, offset: 0, sortBy: "date", diff --git a/packages/core/src/domain/tagging/schemas.ts b/packages/core/src/domain/tagging/schemas.ts index 7ae6c3af..010a4902 100644 --- a/packages/core/src/domain/tagging/schemas.ts +++ b/packages/core/src/domain/tagging/schemas.ts @@ -39,6 +39,50 @@ export const ccipDifferenceRequestSchema = z.object({ feature2: z.array(z.number()), }); +export const ccipVectorStatusSchema = z.object({ + status: z.enum(["missing", "processing", "ready", "stale", "failed"]), + model: z.string().optional(), + extractedAt: z.coerce.date().optional(), + error: z.string().optional(), +}); + +export type CcipVectorStatus = z.infer; + +export const ccipExtractionRequestSchema = z.object({ + mediaSourceId: z.string().uuid(), + mediaId: z.string().uuid(), + force: z.boolean().default(false), +}); + +export const batchCcipExtractionRequestSchema = z.object({ + force: z.boolean().default(false), + mediaSourceId: z.string().uuid().optional(), +}); + +export const startCcipExtractionResponseSchema = z.object({ + success: z.boolean(), + message: z.string(), + jobId: z.string().uuid(), +}); + +export type StartCcipExtractionResponse = z.infer< + typeof startCcipExtractionResponseSchema +>; + +export const similarMediaRequestSchema = z.object({ + anchorMediaId: z.string().uuid(), + mediaSourceId: z.string().uuid().optional(), + topK: z.number().int().min(1).max(100).default(50), +}); + +export const similarMediaScoreSchema = z.object({ + mediaId: z.string().uuid(), + cosineDistance: z.number(), + ccipDistance: z.number(), +}); + +export type SimilarMediaScore = z.infer; + export const batchTaggingRequestSchema = z.object({ force: z.boolean().optional(), batchSize: z.number().optional(), diff --git a/packages/db/src/repositories/job-repository.ts b/packages/db/src/repositories/job-repository.ts index ba93c35d..7d016ec7 100644 --- a/packages/db/src/repositories/job-repository.ts +++ b/packages/db/src/repositories/job-repository.ts @@ -277,7 +277,7 @@ export function createJobRepository( END)->>'processed', '0' )::int + 1)::text::jsonb - ) WHERE id = ${id}`, + ), updated_at = NOW() WHERE id = ${id}`, ); }, @@ -340,7 +340,11 @@ export function createJobRepository( updatedAt: new Date(), }) .where( - and(eq(jobs.status, "in_progress"), lt(jobs.updatedAt, olderThan)), + and( + eq(jobs.status, "in_progress"), + lt(jobs.updatedAt, olderThan), + notInArray(jobs.type, ["batch_ccip_parent", "bulk_tagging_parent"]), + ), ) .returning(); diff --git a/packages/ui/src/hooks/use-current-search-persistence.ts b/packages/ui/src/hooks/use-current-search-persistence.ts index 6969a0c2..13a24fa5 100644 --- a/packages/ui/src/hooks/use-current-search-persistence.ts +++ b/packages/ui/src/hooks/use-current-search-persistence.ts @@ -58,6 +58,21 @@ export function useCurrentSearchPersistence( const sessionDataStr = sessionStorage.getItem(presetName); if (sessionDataStr) { const current = JSON.parse(sessionDataStr); + if (current.mode === "vector") { + resetSearchState(); + setSearchState({ + mode: "vector", + similarityAnchorMediaId: + typeof current.similarityAnchorMediaId === "string" + ? current.similarityAnchorMediaId + : null, + similarityTopK: + current.similarityTopK === 20 || current.similarityTopK === 100 + ? current.similarityTopK + : 50, + }); + return; + } const allPresets = await presetClient.list(); const matchingPreset = allPresets.find( @@ -108,6 +123,8 @@ export function useCurrentSearchPersistence( searchState.selectedCharacters, searchState.selectedAuthors, searchState.advancedCondition, + searchState.similarityAnchorMediaId, + searchState.similarityTopK, searchState.sortBy, searchState.sortOrder, ]; @@ -146,6 +163,8 @@ export function useCurrentSearchPersistence( sort: searchState.sortBy, order: searchState.sortOrder, mode: searchState.mode, + similarityAnchorMediaId: searchState.similarityAnchorMediaId, + similarityTopK: searchState.similarityTopK, }; try { @@ -155,4 +174,3 @@ export function useCurrentSearchPersistence( } }; } - diff --git a/packages/ui/src/hooks/use-manager-page.ts b/packages/ui/src/hooks/use-manager-page.ts index b7e3dab2..46646d9d 100644 --- a/packages/ui/src/hooks/use-manager-page.ts +++ b/packages/ui/src/hooks/use-manager-page.ts @@ -26,6 +26,7 @@ export type ManagerEntityType = | "ips" | "characters" | "tagging" + | "vectors" | "duplicates"; export type ManagerEntity = Project | Ip | Character; @@ -73,11 +74,22 @@ export type ManagerPageActions = { mediaSourceId?: string; mediaIds: string[]; }) => Promise; + scanBatchCcipTargets: (input: { + force: boolean; + mediaSourceId?: string; + }) => Promise; + startBatchCcipExtraction: (input: { + force: boolean; + mediaSourceId?: string; + mediaIds: string[]; + }) => Promise; findDuplicateMedia: ( mediaSourceId?: string, ) => Promise<{ groups: DuplicateGroup[] }>; bulkDeleteMedia: (sourceId: string, mediaIds: string[]) => Promise; - invalidate: (entityType: Exclude) => void; + invalidate: ( + entityType: Exclude, + ) => void; }; export type ManagerPageMutationActions = Omit; @@ -158,6 +170,7 @@ export type UseManagerPageResult = { handleConfirmDelete: () => Promise; handleScan: () => Promise; handleStartBatchTagging: () => Promise; + handleStartBatchCcipExtraction: () => Promise; toggleMediaSelection: (mediaId: string) => void; toggleSelectAll: () => void; jobHandlers: ManagerJobHandlers; @@ -195,8 +208,10 @@ function resetForm(setFormData: Setter) { function activeCrudTab( activeTab: ManagerEntityType, -): Exclude | null { - return activeTab === "tagging" || activeTab === "duplicates" +): Exclude | null { + return activeTab === "tagging" || + activeTab === "vectors" || + activeTab === "duplicates" ? null : activeTab; } @@ -399,10 +414,16 @@ export function useManagerPage( try { setTaggingStatus("Scanning..."); setScannedMedia([]); - const result = await actions.scanBatchTaggingTargets({ - force: forceRetag(), - mediaSourceId: selectedSourceId(), - }); + const result = + activeTab() === "vectors" + ? await actions.scanBatchCcipTargets({ + force: forceRetag(), + mediaSourceId: selectedSourceId(), + }) + : await actions.scanBatchTaggingTargets({ + force: forceRetag(), + mediaSourceId: selectedSourceId(), + }); setScannedMedia(result); setSelectedMedia(new Set(result.map((item) => item.id))); setTaggingStatus(`${result.length} items found.`); @@ -412,6 +433,32 @@ export function useManagerPage( } }; + const handleStartBatchCcipExtraction = async () => { + if (selectedMedia().size === 0) { + toast.error("No media selected"); + return; + } + try { + setTaggingStatus("Starting..."); + setJobProgress(null); + const result = await actions.startBatchCcipExtraction({ + force: forceRetag(), + mediaSourceId: selectedSourceId(), + mediaIds: Array.from(selectedMedia()), + }); + if (result.success && result.jobId) { + toast.success(result.message); + setTaggingStatus("Batch CCIP extraction in progress..."); + setActiveJobId(result.jobId); + setScannedMedia([]); + setSelectedMedia(new Set()); + } + } catch (error) { + toast.error(`Error: ${getErrorMessage(error)}`); + setTaggingStatus(`Error: ${getErrorMessage(error)}`); + } + }; + const handleStartBatchTagging = async () => { if (selectedMedia().size === 0) { toast.error("No media selected"); @@ -642,12 +689,12 @@ export function useManagerPage( const handleJobProgress = (event: JobProgressEvent) => { setJobProgress(event); - setTaggingStatus(`Processing: ${event.processed} / ${event.total} tagged.`); + setTaggingStatus(`Processing: ${event.processed} / ${event.total}.`); }; const handleJobCompleted = (event: JobCompletedEvent) => { - toast.success(event.message || "Batch tagging completed!"); - setTaggingStatus("Batch tagging completed successfully."); + toast.success(event.message || "Batch operation completed!"); + setTaggingStatus("Batch operation completed successfully."); setActiveJobId(null); setJobProgress(null); }; @@ -703,6 +750,7 @@ export function useManagerPage( handleConfirmDelete, handleScan, handleStartBatchTagging, + handleStartBatchCcipExtraction, toggleMediaSelection, toggleSelectAll, jobHandlers, diff --git a/packages/ui/src/hooks/use-search-page.ts b/packages/ui/src/hooks/use-search-page.ts index ed64ff20..e8068640 100644 --- a/packages/ui/src/hooks/use-search-page.ts +++ b/packages/ui/src/hooks/use-search-page.ts @@ -4,6 +4,7 @@ import type { Author, MediaSearchRequest, MediaSearchResponse, + SimilarMediaSearchResponse, } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; import type { SafeMediaSource } from "@solid-imager/core/domain/sources/schemas"; @@ -48,6 +49,11 @@ export interface UseSearchPageOptions { sourceId: string | undefined, params: MediaSearchRequest, ) => Promise; + searchSimilar?: (input: { + anchorMediaId: string; + mediaSourceId?: string; + topK: number; + }) => Promise; queryClient: QueryClient; queries: SearchPageQueryOptions; selectedSource: () => string | null | undefined; @@ -58,6 +64,9 @@ export interface UseSearchPageOptions { scrollY: () => number; setScrollY: (y: number) => void; setOffset: (o: number) => void; + mode?: () => "simple" | "pro" | "vector"; + similarityAnchorMediaId?: () => string | null; + similarityTopK?: () => number; gcTime?: number; refreshDebounceMs?: number; } @@ -93,6 +102,9 @@ export function useSearchPage( scrollY, setScrollY, setOffset, + mode = () => "simple", + similarityAnchorMediaId = () => null, + similarityTopK = () => 50, gcTime = DEFAULT_GC_TIME, refreshDebounceMs = DEFAULT_REFRESH_DEBOUNCE_MS, } = options; @@ -127,19 +139,35 @@ export function useSearchPage( return { queryKey: [ "searchResults", + mode(), source, conditionKey(), sortBy(), sortOrder(), limit(), + similarityAnchorMediaId(), + similarityTopK(), ], - queryFn: async ({ pageParam }) => - await searchMedia(source, { + queryFn: async ({ pageParam }) => { + if (mode() === "vector") { + const anchorMediaId = similarityAnchorMediaId(); + if (!(anchorMediaId && options.searchSimilar)) { + return { media: [], total: 0 }; + } + return await options.searchSimilar({ + anchorMediaId, + mediaSourceId: source, + topK: similarityTopK(), + }); + } + return await searchMedia(source, { ...params, offset: pageParam as number, - }), + }); + }, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { + if (mode() === "vector") return; const loadedCount = allPages.reduce( (sum, page) => sum + page.media.length, 0, diff --git a/packages/ui/src/media-sidebar-content.tsx b/packages/ui/src/media-sidebar-content.tsx index e975c6ec..4e62f7dc 100644 --- a/packages/ui/src/media-sidebar-content.tsx +++ b/packages/ui/src/media-sidebar-content.tsx @@ -2,6 +2,7 @@ import type { Character } from "@solid-imager/core/domain/characters/schemas"; import type { Ip } from "@solid-imager/core/domain/ips/schemas"; import type { MediaDetails } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; +import type { CcipVectorStatus } from "@solid-imager/core/domain/tagging/schemas"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; import type { JSX } from "solid-js"; import { MediaSidebar } from "./media-sidebar"; @@ -20,6 +21,9 @@ export type MediaSidebarContentProps = { isOpen: boolean; onClose: () => void; }) => JSX.Element; + getCcipVectorStatus?: () => Promise; + startCcipExtraction?: (force: boolean) => Promise; + onFindSimilar?: () => void; // biome-ignore lint/suspicious/noExplicitAny: library type mismatch between oRPC and solid-query projectsForMediaQueryOptions: (mediaSourceId: string, mediaId: string) => any; // biome-ignore lint/suspicious/noExplicitAny: library type mismatch between oRPC and solid-query @@ -94,6 +98,9 @@ export function MediaSidebarContent(props: MediaSidebarContentProps) { void; }) => JSX.Element; + getCcipVectorStatus?: () => Promise; + startCcipExtraction?: (force: boolean) => Promise; + onFindSimilar?: () => void; onUpdate?: () => void; onDescriptionUpdate: (description: string) => void | Promise; onProjectAdd: (projectId: string) => void | Promise; @@ -68,6 +73,9 @@ export function MediaSidebar(props: MediaSidebarProps) { const [isCharacterCropModalOpen, setIsCharacterCropModalOpen] = createSignal(false); const [isEditingDescription, setIsEditingDescription] = createSignal(false); + const [ccipStatus, setCcipStatus] = + createSignal("missing"); + const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); const [descriptionValue, setDescriptionValue] = createSignal( props.media.description || "", ); @@ -78,6 +86,33 @@ export function MediaSidebar(props: MediaSidebarProps) { } }); + onMount(async () => { + if (props.getCcipVectorStatus) { + try { + const result = await props.getCcipVectorStatus(); + setCcipStatus(result.status); + } catch { + setCcipStatus("failed"); + } + } + }); + + const extractCcipVector = async () => { + if (!props.startCcipExtraction) return; + setIsExtractingCcip(true); + try { + await props.startCcipExtraction( + ccipStatus() === "ready" || ccipStatus() === "stale", + ); + setCcipStatus("processing"); + toast.success("CCIP vector extraction queued"); + } catch (error) { + toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); + } finally { + setIsExtractingCcip(false); + } + }; + const positiveTags = createMemo(() => tags().filter((tag) => tag.type === "positive"), ); @@ -172,6 +207,26 @@ export function MediaSidebar(props: MediaSidebarProps) { Detect & Crop Characters + + + + + + {props.aiTaggingModal?.({ diff --git a/packages/ui/src/preset-manager.tsx b/packages/ui/src/preset-manager.tsx index 2aa8eef2..9ccc2f2f 100644 --- a/packages/ui/src/preset-manager.tsx +++ b/packages/ui/src/preset-manager.tsx @@ -97,7 +97,7 @@ export function PresetManager(props: { value: condition, sort: searchState.sortBy, order: searchState.sortOrder, - mode: searchState.mode, + mode: searchState.mode === "simple" ? "simple" : "pro", }); setIsSaveDialogOpen(false); setNewPresetName(""); diff --git a/packages/ui/src/screens/config-screen.tsx b/packages/ui/src/screens/config-screen.tsx index 50c5701b..5a92c9b3 100644 --- a/packages/ui/src/screens/config-screen.tsx +++ b/packages/ui/src/screens/config-screen.tsx @@ -161,6 +161,22 @@ export function ConfigScreen(props: ConfigScreenProps) { )} + + + {(field) => ( +
+ + + + + Enable Auto CCIP Extraction + +
+ )} +
diff --git a/packages/ui/src/screens/manager-screen.tsx b/packages/ui/src/screens/manager-screen.tsx index f64f1ba6..f4b186fd 100644 --- a/packages/ui/src/screens/manager-screen.tsx +++ b/packages/ui/src/screens/manager-screen.tsx @@ -73,6 +73,7 @@ const managerTabs: ManagerEntityType[] = [ "ips", "characters", "tagging", + "vectors", "duplicates", ]; @@ -80,6 +81,9 @@ function tabLabel(tab: ManagerEntityType) { if (tab === "tagging") { return "Batch Tagging"; } + if (tab === "vectors") { + return "Vector Extraction"; + } if (tab === "ips") { return "IPs"; } @@ -105,6 +109,7 @@ export function ManagerScreen(props: ManagerScreenProps) { @@ -130,13 +135,24 @@ export function ManagerScreen(props: ManagerScreenProps) { - +
- Batch AI Tagging + + {manager().activeTab() === "vectors" + ? "Batch CCIP Vector Extraction" + : "Batch AI Tagging"} + - Analyze and tag images across your media sources using AI. + {manager().activeTab() === "vectors" + ? "Extract CCIP character embeddings for similarity search." + : "Analyze and tag images across your media sources using AI."} @@ -191,21 +207,33 @@ export function ManagerScreen(props: ManagerScreenProps) { onChange={manager().setForceRetag} > - Force Re-tagging + + {manager().activeTab() === "vectors" + ? "Force Re-extraction" + : "Force Re-tagging"} +

- If checked, existing AI tags will be ignored and images will be - re-analyzed. + {manager().activeTab() === "vectors" + ? "If checked, existing CCIP vectors are overwritten." + : "If checked, existing AI tags will be ignored and images will be re-analyzed."}

@@ -470,6 +498,7 @@ export function ManagerScreen(props: ManagerScreenProps) { @@ -532,7 +561,8 @@ export function ManagerScreen(props: ManagerScreenProps) { {manager().editingItem() ? "Edit" : "Create"}{" "} - {manager().activeTab() === "tagging" + {manager().activeTab() === "tagging" || + manager().activeTab() === "vectors" ? "TAGGING" : manager().activeTab().slice(0, -1).toUpperCase()} diff --git a/packages/ui/src/search-control-panel.tsx b/packages/ui/src/search-control-panel.tsx index 72b27651..df0382fa 100644 --- a/packages/ui/src/search-control-panel.tsx +++ b/packages/ui/src/search-control-panel.tsx @@ -98,16 +98,25 @@ export function SearchControlPanel(props: SearchControlPanelProps) { > 詳細 + - setSearchState("sortBy", value)} - onSortOrderChange={(value) => setSearchState("sortOrder", value)} - sortBy={searchState.sortBy} - sortOrder={searchState.sortOrder} - /> + + setSearchState("sortBy", value)} + onSortOrderChange={(value) => setSearchState("sortOrder", value)} + sortBy={searchState.sortBy} + sortOrder={searchState.sortOrder} + /> +
@@ -143,6 +152,43 @@ export function SearchControlPanel(props: SearchControlPanelProps) { />
+ +
+
+
+ +
+ {searchState.similarityAnchorMediaId ?? + "メディア個別画面の「Find Similar」から選択してください。"} +
+
+
+ +
+ {([20, 50, 100] as const).map((value) => ( + + ))} +
+
+ +

+ CCIPによるキャラクター類似検索です。一般的な画像重複検索とは異なります。 +

+
+
); } diff --git a/packages/ui/src/stores/search-store.ts b/packages/ui/src/stores/search-store.ts index 55564cbb..5c812dda 100644 --- a/packages/ui/src/stores/search-store.ts +++ b/packages/ui/src/stores/search-store.ts @@ -34,10 +34,32 @@ export const loadPreset = (preset: Preset) => { setSearchState(nextState); }; -export const setSearchMode = (mode: "simple" | "pro") => { +export const setSearchMode = (mode: "simple" | "pro" | "vector") => { const nextState = calculateNextModeState(searchState, mode); setSearchState(nextState); }; +export const activateVectorSearch = (mediaId: string) => { + const nextState = { + mode: "vector" as const, + similarityAnchorMediaId: mediaId, + similarityTopK: 50 as const, + selectedSource: "", + offset: 0, + scrollY: 0, + }; + setSearchState(nextState); + if (typeof sessionStorage !== "undefined") { + sessionStorage.setItem( + "current-all", + JSON.stringify({ + mode: nextState.mode, + similarityAnchorMediaId: nextState.similarityAnchorMediaId, + similarityTopK: nextState.similarityTopK, + }), + ); + } +}; + export const getSearchCondition = () => getSearchConditionFromState(searchState); From 962f0edbefc75ec810a27a2a3b04f5f8a0bcf48f Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 28 Jun 2026 22:48:29 +0900 Subject: [PATCH 3/6] fix(ccip): address review feedback --- apps/server/public/openapi.json | 26 ++++ .../src/components/media/media-sidebar.tsx | 30 +++- .../src/infrastructure/ai/rust-ai-client.ts | 11 ++ .../infrastructure/api/routers/ai-router.ts | 35 ++++- .../api/routers/media-router.ts | 12 +- .../src/infrastructure/jobs/ccip-jobs.ts | 14 +- .../src/infrastructure/jobs/tagging-jobs.ts | 6 +- .../infrastructure/jobs/ccip-jobs.test.ts | 131 ++++++++++++++++++ .../infrastructure/jobs/tagging-jobs.test.ts | 90 ++++++++++++ .../media-sidebar/media-sidebar-content.tsx | 2 + .../core/src/domain/contract/ai.contract.ts | 6 + .../src/domain/repositories/job-repository.ts | 2 +- packages/core/src/domain/tagging/schemas.ts | 10 ++ .../db/src/repositories/job-repository.ts | 57 +++++--- packages/ui/src/media-sidebar-content.tsx | 23 ++- packages/ui/src/media-sidebar.tsx | 49 ++++++- 16 files changed, 462 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/tests/unit/infrastructure/jobs/ccip-jobs.test.ts create mode 100644 apps/server/src/tests/unit/infrastructure/jobs/tagging-jobs.test.ts diff --git a/apps/server/public/openapi.json b/apps/server/public/openapi.json index 1c85431c..a6541b95 100644 --- a/apps/server/public/openapi.json +++ b/apps/server/public/openapi.json @@ -2096,6 +2096,32 @@ } } }, + "/ai/ccipDistances": { + "post": { + "operationId": "ai.ccipDistances", + "summary": "ccipDistances", + "tags": [ + "AI" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "not": {} + } + ] + } + } + } + } + } + } + }, "/ai/scanBatchTaggingTargets": { "post": { "operationId": "ai.scanBatchTaggingTargets", diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index dd28299e..0df5bea2 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -37,6 +37,7 @@ import { allProjectsQueryOptions, projectsForMediaQueryOptions, } from "~/infrastructure/api-clients/queries"; +import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; type MediaSidebarProps = { media: MediaDetails; @@ -93,29 +94,37 @@ export function MediaSidebar(props: MediaSidebarProps) { const [ccipStatus, setCcipStatus] = createSignal< "missing" | "processing" | "ready" | "stale" | "failed" >("missing"); + const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); - onMount(async () => { + const refreshCcipStatus = async () => { try { const result = await getCcipVectorStatus( props.media.mediaSourceId, props.media.id, ); setCcipStatus(result.status); + setActiveCcipJobId(result.jobId ?? null); } catch { setCcipStatus("failed"); + setActiveCcipJobId(null); } + }; + + onMount(() => { + void refreshCcipStatus(); }); const handleCcipExtraction = async () => { setIsExtractingCcip(true); try { - await startCcipExtraction( + const result = await startCcipExtraction( props.media.mediaSourceId, props.media.id, ccipStatus() === "ready" || ccipStatus() === "stale", ); setCcipStatus("processing"); + setActiveCcipJobId(result.jobId); toast.success("CCIP vector extraction queued"); } catch (error) { toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); @@ -124,6 +133,23 @@ export function MediaSidebar(props: MediaSidebarProps) { } }; + useBatchJobEvents(() => activeCcipJobId(), { + handleJobProgress: () => { + setCcipStatus("processing"); + }, + handleJobCompleted: () => { + setActiveCcipJobId(null); + void refreshCcipStatus(); + }, + handleJobFailed: (event) => { + setCcipStatus("failed"); + setActiveCcipJobId(null); + if (event.error) { + toast.error(`Failed to extract CCIP vector: ${event.error}`); + } + }, + }); + // Description editing state const [isEditingDescription, setIsEditingDescription] = createSignal(false); const [descriptionValue, setDescriptionValue] = createSignal( diff --git a/apps/server/src/infrastructure/ai/rust-ai-client.ts b/apps/server/src/infrastructure/ai/rust-ai-client.ts index 2ff25ae8..6a97ffee 100644 --- a/apps/server/src/infrastructure/ai/rust-ai-client.ts +++ b/apps/server/src/infrastructure/ai/rust-ai-client.ts @@ -261,6 +261,17 @@ export class RustAiClient implements IAiClient { feature: number[], candidates: number[][], ): Promise { + if (this.baseUrl) { + if (!this.client) { + throw new Error("Client is not initialized (baseUrl is empty)"); + } + const result = await this.client.ai.ccipDistances({ + feature, + candidates, + }); + return result.distances; + } + if (!this.baseUrl) { const nativeModule: unknown = await import("dghs-imgutils-rs"); if (hasCcipDistances(nativeModule)) { diff --git a/apps/server/src/infrastructure/api/routers/ai-router.ts b/apps/server/src/infrastructure/api/routers/ai-router.ts index 68f91419..1c143086 100644 --- a/apps/server/src/infrastructure/api/routers/ai-router.ts +++ b/apps/server/src/infrastructure/api/routers/ai-router.ts @@ -15,6 +15,8 @@ import { batchCcipExtractionRequestSchema, batchTaggingRequestSchema, ccipDifferenceRequestSchema, + ccipDistancesRequestSchema, + ccipDistancesResponseSchema, ccipExtractionRequestSchema, ccipFeatureRequestSchema, ccipVectorStatusSchema, @@ -272,6 +274,16 @@ export const aiRouter = { await taggingService.getCcipDifference(input.feature1, input.feature2), ), + ccipDistances: os + .input(ccipDistancesRequestSchema) + .output(ccipDistancesResponseSchema) + .handler(async ({ input }) => ({ + distances: await taggingService.getCcipDistances( + input.feature, + input.candidates, + ), + })), + scanBatchTaggingTargets: os .input(batchTaggingRequestSchema) .output(z.array(mediaSchema)) @@ -346,6 +358,7 @@ export const aiRouter = { payload: { total: mediaIds.length, processed: 0, + processedJobIds: [], }, }); @@ -390,6 +403,10 @@ export const aiRouter = { ) .output(ccipVectorStatusSchema) .handler(async ({ input }) => { + const status = await ccipVectorService.getStatus( + input.mediaSourceId, + input.mediaId, + ); const latestJob = await db.query.jobs.findFirst({ where: and( eq(jobs.type, "extract_ccip_vector"), @@ -398,19 +415,19 @@ export const aiRouter = { ), orderBy: desc(jobs.createdAt), }); + if (status.status === "ready" || status.status === "stale") { + return status; + } if ( latestJob?.status === "pending" || latestJob?.status === "in_progress" ) { - return { status: "processing" as const }; + return { status: "processing" as const, jobId: latestJob.id }; } - const status = await ccipVectorService.getStatus( - input.mediaSourceId, - input.mediaId, - ); - if (latestJob?.status === "failed" && status.status !== "ready") { + if (latestJob?.status === "failed") { return { status: "failed" as const, + jobId: latestJob.id, error: latestJob.error ?? "CCIP vector extraction failed", }; } @@ -495,7 +512,11 @@ export const aiRouter = { type: "batch_ccip_parent", status: "in_progress", mediaSourceId: input.mediaSourceId, - payload: { total: mediaItems.length, processed: 0 }, + payload: { + total: mediaItems.length, + processed: 0, + processedJobIds: [], + }, }); await Promise.all( mediaItems.map((media) => diff --git a/apps/server/src/infrastructure/api/routers/media-router.ts b/apps/server/src/infrastructure/api/routers/media-router.ts index 72063b30..20f7982d 100644 --- a/apps/server/src/infrastructure/api/routers/media-router.ts +++ b/apps/server/src/infrastructure/api/routers/media-router.ts @@ -229,10 +229,14 @@ export const mediaRouter = { targetSourceId: z.string().uuid(), }), ) - .handler( - async ({ input }) => - await MediaService.moveMedia(input.mediaId, input.targetSourceId), - ), + .handler(async ({ input }) => { + const result = await MediaService.moveMedia( + input.mediaId, + input.targetSourceId, + ); + await ccipVectorService.delete(input.mediaId); + return result; + }), /** * Upload media to a source diff --git a/apps/server/src/infrastructure/jobs/ccip-jobs.ts b/apps/server/src/infrastructure/jobs/ccip-jobs.ts index a175454a..1209190e 100644 --- a/apps/server/src/infrastructure/jobs/ccip-jobs.ts +++ b/apps/server/src/infrastructure/jobs/ccip-jobs.ts @@ -14,12 +14,16 @@ const payloadSchema = z.object({ const parentPayloadSchema = z.object({ total: z.number().int().nonnegative(), processed: z.number().int().nonnegative(), + processedJobIds: z.array(z.string().uuid()).optional(), }); async function updateParent(job: Job): Promise { if (!job.parentId) return; const jobRepository = services.getJobRepository(); - await jobRepository.incrementProgress(job.parentId); + const updated = await jobRepository.incrementProgress(job.parentId, job.id); + if (!updated) { + return; + } const parent = await jobRepository.findById(job.parentId); if (!parent) return; const payload = parentPayloadSchema.parse(parent.payload); @@ -48,12 +52,20 @@ export async function processCcipExtractionJob(job: Job): Promise { payload.mediaId, payload.force, ); + RealtimeEventBus.publishJob("job-completed", { + jobId: job.id, + message: "CCIP vector extraction completed", + }); await updateParent(job); } catch (error) { logger.error( { err: error, mediaId: payload.mediaId }, "CCIP vector extraction failed", ); + RealtimeEventBus.publishJob("job-failed", { + jobId: job.id, + error: getErrorMessage(error), + }); if (job.parentId) { await services .getJobRepository() diff --git a/apps/server/src/infrastructure/jobs/tagging-jobs.ts b/apps/server/src/infrastructure/jobs/tagging-jobs.ts index be65aaa3..11460eae 100644 --- a/apps/server/src/infrastructure/jobs/tagging-jobs.ts +++ b/apps/server/src/infrastructure/jobs/tagging-jobs.ts @@ -48,13 +48,17 @@ export async function processAutoTaggingJob(job: Job): Promise { if (parentId) { const jobRepo = services.getJobRepository(); - await jobRepo.incrementProgress(parentId); + const updated = await jobRepo.incrementProgress(parentId, job.id); + if (!updated) { + return; + } const parentJob = await jobRepo.findById(parentId); if (parentJob) { const parentPayloadSchema = z.object({ total: z.number(), processed: z.number(), + processedJobIds: z.array(z.string().uuid()).optional(), }); try { const parentPayload = parentPayloadSchema.parse(parentJob.payload); diff --git a/apps/server/src/tests/unit/infrastructure/jobs/ccip-jobs.test.ts b/apps/server/src/tests/unit/infrastructure/jobs/ccip-jobs.test.ts new file mode 100644 index 00000000..d62b4f3c --- /dev/null +++ b/apps/server/src/tests/unit/infrastructure/jobs/ccip-jobs.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { IJobRepository } from "~/domain/repositories/job-repository"; +import { processCcipExtractionJob } from "~/infrastructure/jobs/ccip-jobs"; + +const publishJob = vi.fn(); +const extract = vi.fn(); +const loggerError = vi.fn(); + +const jobRepository: IJobRepository = { + create: vi.fn(), + createIfUnique: vi.fn(), + findById: vi.fn(), + findPending: vi.fn(), + markAsInProgress: vi.fn(), + markAsCompleted: vi.fn(), + markAsFailed: vi.fn(), + update: vi.fn(), + incrementProgress: vi.fn(), + claimPending: vi.fn(), + requeueStaleInProgress: vi.fn(), +}; + +vi.mock("~/application/registry", () => ({ + services: { + getJobRepository: () => jobRepository, + }, +})); + +vi.mock("~/application/services/ccip-vector-service", () => ({ + ccipVectorService: { + extract: (...args: Parameters) => extract(...args), + }, +})); + +vi.mock("~/infrastructure/events/realtime-event-bus", () => ({ + RealtimeEventBus: { + publishJob: (...args: Parameters) => publishJob(...args), + }, +})); + +vi.mock("~/infrastructure/logger", () => ({ + logger: { + error: (...args: Parameters) => loggerError(...args), + }, +})); + +describe("processCcipExtractionJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("publishes child completion and completes the parent once", async () => { + extract.mockResolvedValue(undefined); + vi.mocked(jobRepository.incrementProgress).mockResolvedValue(true); + vi.mocked(jobRepository.findById).mockResolvedValue({ + id: "00000000-0000-4000-8000-000000000010", + type: "batch_ccip_parent", + mediaSourceId: "00000000-0000-4000-8000-000000000001", + status: "in_progress", + payload: { + total: 1, + processed: 1, + processedJobIds: ["00000000-0000-4000-8000-000000000020"], + }, + result: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + parentId: null, + }); + + await processCcipExtractionJob({ + id: "00000000-0000-4000-8000-000000000020", + type: "extract_ccip_vector", + mediaSourceId: "00000000-0000-4000-8000-000000000001", + status: "in_progress", + payload: { + mediaId: "00000000-0000-4000-8000-000000000030", + force: false, + }, + result: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + parentId: "00000000-0000-4000-8000-000000000010", + }); + + expect(extract).toHaveBeenCalled(); + expect(jobRepository.incrementProgress).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000010", + "00000000-0000-4000-8000-000000000020", + ); + expect(publishJob).toHaveBeenCalledWith("job-completed", { + jobId: "00000000-0000-4000-8000-000000000020", + message: "CCIP vector extraction completed", + }); + expect(publishJob).toHaveBeenCalledWith("job-progress", { + jobId: "00000000-0000-4000-8000-000000000010", + processed: 1, + total: 1, + }); + expect(jobRepository.markAsCompleted).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000010", + { success: true }, + ); + }); + + it("skips parent progress when the child was already counted", async () => { + extract.mockResolvedValue(undefined); + vi.mocked(jobRepository.incrementProgress).mockResolvedValue(false); + + await processCcipExtractionJob({ + id: "00000000-0000-4000-8000-000000000021", + type: "extract_ccip_vector", + mediaSourceId: "00000000-0000-4000-8000-000000000001", + status: "in_progress", + payload: { + mediaId: "00000000-0000-4000-8000-000000000031", + force: false, + }, + result: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + parentId: "00000000-0000-4000-8000-000000000011", + }); + + expect(jobRepository.findById).not.toHaveBeenCalled(); + expect(jobRepository.markAsCompleted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/tests/unit/infrastructure/jobs/tagging-jobs.test.ts b/apps/server/src/tests/unit/infrastructure/jobs/tagging-jobs.test.ts new file mode 100644 index 00000000..9e488944 --- /dev/null +++ b/apps/server/src/tests/unit/infrastructure/jobs/tagging-jobs.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { IJobRepository } from "~/domain/repositories/job-repository"; +import { processAutoTaggingJob } from "~/infrastructure/jobs/tagging-jobs"; + +const createIfUnique = vi.fn(); +const incrementProgress = vi.fn(); +const findById = vi.fn(); +const publishJob = vi.fn(); +const getTagsForMedia = vi.fn(); + +const jobRepository: IJobRepository = { + create: vi.fn(), + createIfUnique: (...args: Parameters) => + createIfUnique(...args), + findById: (...args: Parameters) => findById(...args), + findPending: vi.fn(), + markAsInProgress: vi.fn(), + markAsCompleted: vi.fn(), + markAsFailed: vi.fn(), + update: vi.fn(), + incrementProgress: (...args: Parameters) => + incrementProgress(...args), + claimPending: vi.fn(), + requeueStaleInProgress: vi.fn(), +}; + +vi.mock("~/application/registry", () => ({ + services: { + getJobRepository: () => jobRepository, + }, +})); + +vi.mock("~/application/services/tagging-service", () => ({ + taggingService: { + getTagsForMedia: (...args: Parameters) => + getTagsForMedia(...args), + }, +})); + +vi.mock("~/infrastructure/db", () => ({ + db: {}, +})); + +vi.mock("~/infrastructure/events/realtime-event-bus", () => ({ + RealtimeEventBus: { + publishJob: (...args: Parameters) => publishJob(...args), + }, +})); + +vi.mock("~/infrastructure/logger", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})); + +describe("processAutoTaggingJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + getTagsForMedia.mockResolvedValue({}); + createIfUnique.mockResolvedValue(null); + incrementProgress.mockResolvedValue(false); + }); + + it("does not re-publish parent progress when the child was already counted", async () => { + await processAutoTaggingJob({ + id: "00000000-0000-4000-8000-000000000020", + type: "auto_tagging", + mediaSourceId: "00000000-0000-4000-8000-000000000001", + status: "in_progress", + payload: { + mediaId: "00000000-0000-4000-8000-000000000030", + force: false, + }, + result: null, + error: null, + createdAt: new Date(), + updatedAt: new Date(), + parentId: "00000000-0000-4000-8000-000000000010", + }); + + expect(incrementProgress).toHaveBeenCalledWith( + "00000000-0000-4000-8000-000000000010", + "00000000-0000-4000-8000-000000000020", + ); + expect(findById).not.toHaveBeenCalled(); + expect(publishJob).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx b/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx index e8dd47ff..d995c4f2 100644 --- a/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx +++ b/apps/tauri/src/components/media/media-sidebar/media-sidebar-content.tsx @@ -18,6 +18,7 @@ import { createProject, removeProjectFromMedia, } from "~/infrastructure/api-clients/projects-api"; +import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; import { buildMediaContentUrl } from "~/infrastructure/media/thumbnail-runtime"; import { getApiFetch } from "~/infrastructure/tauri-fetch-helpers"; import { client } from "~/orpc-client"; @@ -88,6 +89,7 @@ export function MediaSidebar(props: MediaSidebarProps) { mediaId: props.media.id, }) } + useCcipJobEvents={useBatchJobEvents} startCcipExtraction={(force) => client.ai.startCcipExtraction({ mediaSourceId: props.media.mediaSourceId, diff --git a/packages/core/src/domain/contract/ai.contract.ts b/packages/core/src/domain/contract/ai.contract.ts index c9dbc5cb..a5be21ab 100644 --- a/packages/core/src/domain/contract/ai.contract.ts +++ b/packages/core/src/domain/contract/ai.contract.ts @@ -5,6 +5,8 @@ import { batchCcipExtractionRequestSchema, batchTaggingRequestSchema, ccipDifferenceRequestSchema, + ccipDistancesRequestSchema, + ccipDistancesResponseSchema, ccipExtractionRequestSchema, ccipFeatureRequestSchema, ccipVectorStatusSchema, @@ -34,6 +36,10 @@ export const aiContract = { ccipDifference: oc.input(ccipDifferenceRequestSchema), + ccipDistances: oc + .input(ccipDistancesRequestSchema) + .output(ccipDistancesResponseSchema), + ccipVectorStatus: oc .input( ccipExtractionRequestSchema.pick({ mediaSourceId: true, mediaId: true }), diff --git a/packages/core/src/domain/repositories/job-repository.ts b/packages/core/src/domain/repositories/job-repository.ts index b1becb9c..e4a7c74d 100644 --- a/packages/core/src/domain/repositories/job-repository.ts +++ b/packages/core/src/domain/repositories/job-repository.ts @@ -42,7 +42,7 @@ export type IJobRepository = { markAsCompleted(id: string, result?: unknown): Promise; markAsFailed(id: string, error: string): Promise; update(id: string, data: Partial): Promise; - incrementProgress(id: string): Promise; + incrementProgress(id: string, progressKey?: string): Promise; claimPending( limit: number, options?: { diff --git a/packages/core/src/domain/tagging/schemas.ts b/packages/core/src/domain/tagging/schemas.ts index 010a4902..b246fb43 100644 --- a/packages/core/src/domain/tagging/schemas.ts +++ b/packages/core/src/domain/tagging/schemas.ts @@ -39,8 +39,18 @@ export const ccipDifferenceRequestSchema = z.object({ feature2: z.array(z.number()), }); +export const ccipDistancesRequestSchema = z.object({ + feature: z.array(z.number()), + candidates: z.array(z.array(z.number())), +}); + +export const ccipDistancesResponseSchema = z.object({ + distances: z.array(z.number()), +}); + export const ccipVectorStatusSchema = z.object({ status: z.enum(["missing", "processing", "ready", "stale", "failed"]), + jobId: z.string().uuid().optional(), model: z.string().optional(), extractedAt: z.coerce.date().optional(), error: z.string().optional(), diff --git a/packages/db/src/repositories/job-repository.ts b/packages/db/src/repositories/job-repository.ts index 7d016ec7..ac66f46b 100644 --- a/packages/db/src/repositories/job-repository.ts +++ b/packages/db/src/repositories/job-repository.ts @@ -259,26 +259,45 @@ export function createJobRepository( await db().update(jobs).set(updates).where(eq(jobs.id, id)); }, - async incrementProgress(id: string): Promise { - await db().execute( - sql`UPDATE ${jobs} SET payload = jsonb_set( - COALESCE( - CASE - WHEN jsonb_typeof(payload) = 'string' THEN (payload#>>'{}')::jsonb - ELSE payload - END, - '{}'::jsonb - ), - '{processed}', - (COALESCE( - (CASE - WHEN jsonb_typeof(payload) = 'string' THEN (payload#>>'{}')::jsonb - ELSE payload - END)->>'processed', - '0' - )::int + 1)::text::jsonb - ), updated_at = NOW() WHERE id = ${id}`, + async incrementProgress( + id: string, + progressKey?: string, + ): Promise { + const normalizedPayload = sql`COALESCE( + CASE + WHEN jsonb_typeof(payload) = 'string' THEN (payload#>>'{}')::jsonb + ELSE payload + END, + '{}'::jsonb + )`; + const processedJobIds = sql`COALESCE(${normalizedPayload}->'processedJobIds', '[]'::jsonb)`; + const result: unknown = await db().execute( + progressKey + ? sql`UPDATE ${jobs} + SET payload = jsonb_set( + jsonb_set( + ${normalizedPayload}, + '{processed}', + (COALESCE((${normalizedPayload}->>'processed'), '0')::int + 1)::text::jsonb + ), + '{processedJobIds}', + ${processedJobIds} || jsonb_build_array(${progressKey}::text) + ), + updated_at = NOW() + WHERE id = ${id} + AND NOT (${processedJobIds} @> jsonb_build_array(${progressKey}::text)) + RETURNING id` + : sql`UPDATE ${jobs} + SET payload = jsonb_set( + ${normalizedPayload}, + '{processed}', + (COALESCE((${normalizedPayload}->>'processed'), '0')::int + 1)::text::jsonb + ), + updated_at = NOW() + WHERE id = ${id} + RETURNING id`, ); + return extractRows(result).length > 0; }, async claimPending( diff --git a/packages/ui/src/media-sidebar-content.tsx b/packages/ui/src/media-sidebar-content.tsx index 4e62f7dc..ff7182fd 100644 --- a/packages/ui/src/media-sidebar-content.tsx +++ b/packages/ui/src/media-sidebar-content.tsx @@ -2,9 +2,17 @@ import type { Character } from "@solid-imager/core/domain/characters/schemas"; import type { Ip } from "@solid-imager/core/domain/ips/schemas"; import type { MediaDetails } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; -import type { CcipVectorStatus } from "@solid-imager/core/domain/tagging/schemas"; +import type { + CcipVectorStatus, + StartCcipExtractionResponse, +} from "@solid-imager/core/domain/tagging/schemas"; +import type { + JobCompletedEvent, + JobFailedEvent, + JobProgressEvent, +} from "@solid-imager/core/domain/sources/events"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import type { JSX } from "solid-js"; +import type { Accessor, JSX } from "solid-js"; import { MediaSidebar } from "./media-sidebar"; import { projectsQueryKeys } from "./query-options"; @@ -22,7 +30,15 @@ export type MediaSidebarContentProps = { onClose: () => void; }) => JSX.Element; getCcipVectorStatus?: () => Promise; - startCcipExtraction?: (force: boolean) => Promise; + startCcipExtraction?: (force: boolean) => Promise; + useCcipJobEvents?: ( + activeJobId: Accessor, + handlers: { + handleJobProgress: (event: JobProgressEvent) => void; + handleJobCompleted: (event: JobCompletedEvent) => void; + handleJobFailed: (event: JobFailedEvent) => void; + }, + ) => void; onFindSimilar?: () => void; // biome-ignore lint/suspicious/noExplicitAny: library type mismatch between oRPC and solid-query projectsForMediaQueryOptions: (mediaSourceId: string, mediaId: string) => any; @@ -100,6 +116,7 @@ export function MediaSidebarContent(props: MediaSidebarContentProps) { characterCropModal={props.characterCropModal} getCcipVectorStatus={props.getCcipVectorStatus} startCcipExtraction={props.startCcipExtraction} + useCcipJobEvents={props.useCcipJobEvents} onFindSimilar={props.onFindSimilar} allCharacters={allCharacters.data || []} allIps={allIps.data || []} diff --git a/packages/ui/src/media-sidebar.tsx b/packages/ui/src/media-sidebar.tsx index ba825fef..1132f04c 100644 --- a/packages/ui/src/media-sidebar.tsx +++ b/packages/ui/src/media-sidebar.tsx @@ -2,7 +2,15 @@ import type { Character } from "@solid-imager/core/domain/characters/schemas"; import type { Ip } from "@solid-imager/core/domain/ips/schemas"; import type { MediaDetails } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; -import type { CcipVectorStatus } from "@solid-imager/core/domain/tagging/schemas"; +import type { + CcipVectorStatus, + StartCcipExtractionResponse, +} from "@solid-imager/core/domain/tagging/schemas"; +import type { + JobCompletedEvent, + JobFailedEvent, + JobProgressEvent, +} from "@solid-imager/core/domain/sources/events"; import { getErrorMessage } from "@solid-imager/core/utils"; import { createEffect, @@ -40,7 +48,15 @@ type MediaSidebarProps = { onClose: () => void; }) => JSX.Element; getCcipVectorStatus?: () => Promise; - startCcipExtraction?: (force: boolean) => Promise; + startCcipExtraction?: (force: boolean) => Promise; + useCcipJobEvents?: ( + activeJobId: () => string | null, + handlers: { + handleJobProgress: (event: JobProgressEvent) => void; + handleJobCompleted: (event: JobCompletedEvent) => void; + handleJobFailed: (event: JobFailedEvent) => void; + }, + ) => void; onFindSimilar?: () => void; onUpdate?: () => void; onDescriptionUpdate: (description: string) => void | Promise; @@ -75,6 +91,7 @@ export function MediaSidebar(props: MediaSidebarProps) { const [isEditingDescription, setIsEditingDescription] = createSignal(false); const [ccipStatus, setCcipStatus] = createSignal("missing"); + const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); const [descriptionValue, setDescriptionValue] = createSignal( props.media.description || "", @@ -86,25 +103,32 @@ export function MediaSidebar(props: MediaSidebarProps) { } }); - onMount(async () => { + const refreshCcipStatus = async () => { if (props.getCcipVectorStatus) { try { const result = await props.getCcipVectorStatus(); setCcipStatus(result.status); + setActiveCcipJobId(result.jobId ?? null); } catch { setCcipStatus("failed"); + setActiveCcipJobId(null); } } + }; + + onMount(() => { + void refreshCcipStatus(); }); const extractCcipVector = async () => { if (!props.startCcipExtraction) return; setIsExtractingCcip(true); try { - await props.startCcipExtraction( + const result = await props.startCcipExtraction( ccipStatus() === "ready" || ccipStatus() === "stale", ); setCcipStatus("processing"); + setActiveCcipJobId(result.jobId); toast.success("CCIP vector extraction queued"); } catch (error) { toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); @@ -113,6 +137,23 @@ export function MediaSidebar(props: MediaSidebarProps) { } }; + props.useCcipJobEvents?.(activeCcipJobId, { + handleJobProgress: () => { + setCcipStatus("processing"); + }, + handleJobCompleted: () => { + setActiveCcipJobId(null); + void refreshCcipStatus(); + }, + handleJobFailed: (event) => { + setCcipStatus("failed"); + setActiveCcipJobId(null); + if (event.error) { + toast.error(`Failed to extract CCIP vector: ${event.error}`); + } + }, + }); + const positiveTags = createMemo(() => tags().filter((tag) => tag.type === "positive"), ); From e8017bf18d79031df0c64fc65717c8ff965fa94a Mon Sep 17 00:00:00 2001 From: hmjn Date: Sun, 28 Jun 2026 23:05:36 +0900 Subject: [PATCH 4/6] fix(ccip): handle follow-up review comments --- .../src/components/media/media-sidebar.tsx | 17 +++++++++++++++-- .../server/src/infrastructure/jobs/ccip-jobs.ts | 2 +- .../src/services/ccip-vector-service.ts | 6 ++++++ packages/ui/src/media-sidebar.tsx | 15 ++++++++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index 0df5bea2..82997361 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -7,7 +7,7 @@ import { activateVectorSearch } from "@solid-imager/ui/stores/search-store"; import { toast } from "@solid-imager/ui/toast"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { useNavigate } from "@tanstack/solid-router"; -import { createMemo, createSignal, For, onMount, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { AiTaggingModal } from "~/components/media/ai-tagging-modal"; import AssociationManager from "~/components/media/association-manager"; import CharacterCropModal from "~/components/media/character-crop-modal"; @@ -96,22 +96,35 @@ export function MediaSidebar(props: MediaSidebarProps) { >("missing"); const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); + const [ccipStatusRequestId, setCcipStatusRequestId] = createSignal(0); const refreshCcipStatus = async () => { + const requestId = ccipStatusRequestId() + 1; + setCcipStatusRequestId(requestId); try { const result = await getCcipVectorStatus( props.media.mediaSourceId, props.media.id, ); + if (ccipStatusRequestId() !== requestId) { + return; + } setCcipStatus(result.status); setActiveCcipJobId(result.jobId ?? null); } catch { + if (ccipStatusRequestId() !== requestId) { + return; + } setCcipStatus("failed"); setActiveCcipJobId(null); } }; - onMount(() => { + createEffect(() => { + props.media.id; + props.media.mediaSourceId; + setCcipStatus("missing"); + setActiveCcipJobId(null); void refreshCcipStatus(); }); diff --git a/apps/server/src/infrastructure/jobs/ccip-jobs.ts b/apps/server/src/infrastructure/jobs/ccip-jobs.ts index 1209190e..9072cf91 100644 --- a/apps/server/src/infrastructure/jobs/ccip-jobs.ts +++ b/apps/server/src/infrastructure/jobs/ccip-jobs.ts @@ -32,7 +32,7 @@ async function updateParent(job: Job): Promise { processed: payload.processed, total: payload.total, }); - if (payload.processed >= payload.total) { + if (parent.status !== "failed" && payload.processed >= payload.total) { await jobRepository.markAsCompleted(parent.id, { success: true }); RealtimeEventBus.publishJob("job-completed", { jobId: parent.id, diff --git a/packages/application/src/services/ccip-vector-service.ts b/packages/application/src/services/ccip-vector-service.ts index 945aa087..5ceba9e4 100644 --- a/packages/application/src/services/ccip-vector-service.ts +++ b/packages/application/src/services/ccip-vector-service.ts @@ -111,6 +111,9 @@ export class CcipVectorService { mediaSourceId, ) ).filter((candidate) => candidate.mediaId !== anchorMediaId); + if (candidates.length === 0) { + return { media: [], total: 0, scores: [] }; + } const media = await this.deps.mediaRepository.findByIds( candidates.map((candidate) => candidate.mediaId), ); @@ -119,6 +122,9 @@ export class CcipVectorService { const item = mediaById.get(candidate.mediaId); return item ? this.isCurrent(candidate, item) : false; }); + if (currentCandidates.length === 0) { + return { media: [], total: 0, scores: [] }; + } const distances = await this.deps.taggingService.getCcipDistances( anchor.vector, currentCandidates.map((candidate) => candidate.vector), diff --git a/packages/ui/src/media-sidebar.tsx b/packages/ui/src/media-sidebar.tsx index 1132f04c..27cffb4d 100644 --- a/packages/ui/src/media-sidebar.tsx +++ b/packages/ui/src/media-sidebar.tsx @@ -93,6 +93,7 @@ export function MediaSidebar(props: MediaSidebarProps) { createSignal("missing"); const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); + const [ccipStatusRequestId, setCcipStatusRequestId] = createSignal(0); const [descriptionValue, setDescriptionValue] = createSignal( props.media.description || "", ); @@ -104,19 +105,31 @@ export function MediaSidebar(props: MediaSidebarProps) { }); const refreshCcipStatus = async () => { + const requestId = ccipStatusRequestId() + 1; + setCcipStatusRequestId(requestId); if (props.getCcipVectorStatus) { try { const result = await props.getCcipVectorStatus(); + if (ccipStatusRequestId() !== requestId) { + return; + } setCcipStatus(result.status); setActiveCcipJobId(result.jobId ?? null); } catch { + if (ccipStatusRequestId() !== requestId) { + return; + } setCcipStatus("failed"); setActiveCcipJobId(null); } } }; - onMount(() => { + createEffect(() => { + props.media.id; + props.media.mediaSourceId; + setCcipStatus("missing"); + setActiveCcipJobId(null); void refreshCcipStatus(); }); From 99b4e5105e4265967db9dd336b1b4df1e7d4715e Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 29 Jun 2026 01:23:45 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20PR=20#556=20=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - media-sidebar: AbortController パターンでリアクティビティ問題を修正 - media-router: ベクター削除失敗時の try-catch 追加 - rust-ai-client: asyncPool で並列度を制限 (CPU過剰消費防止) - lancedb-ccip-vector-store: 失敗時 Promise リセット追加 - ai-router: mediaSourceId フィルタ追加 - media-source-service: ベクター削除の try-catch 追加 - ccip-vector-service: isCurrent に mediaSourceId チェック追加 - media-processing-service: createIfUnique で重複投入防止 - ai.contract: mediaSafeSchema 使用 (filePath 除外) - search/logic: ベクトルモード切替時の条件保持 - tagging/schemas: CCIP ベクトル長を 768 に固定 - ui/media-sidebar: メディア切り替え後の抽出結果反映を修正 --- .../services/media-source-service.ts | 7 +++++- .../src/components/media/media-sidebar.tsx | 20 ++++++++-------- .../ai/lancedb-ccip-vector-store.ts | 16 +++++++++---- .../src/infrastructure/ai/rust-ai-client.ts | 15 +++++++----- .../infrastructure/api/routers/ai-router.ts | 1 + .../api/routers/media-router.ts | 23 +++++++++++++++---- .../src/services/ccip-vector-service.ts | 11 +++++---- .../src/services/media-processing-service.ts | 2 +- .../core/src/domain/contract/ai.contract.ts | 6 ++--- packages/core/src/domain/media/schemas.ts | 3 +++ packages/core/src/domain/search/logic.ts | 3 ++- packages/core/src/domain/tagging/schemas.ts | 4 ++-- packages/ui/src/media-sidebar.tsx | 3 +++ 13 files changed, 77 insertions(+), 37 deletions(-) diff --git a/apps/server/src/application/services/media-source-service.ts b/apps/server/src/application/services/media-source-service.ts index 1765ecd0..f8ba6b3a 100644 --- a/apps/server/src/application/services/media-source-service.ts +++ b/apps/server/src/application/services/media-source-service.ts @@ -4,6 +4,7 @@ import type { } from "@solid-imager/core/domain/repositories/source-repository"; import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { DrizzleSourceRepository } from "~/infrastructure/repositories/source-repository"; +import { logger } from "~/infrastructure/logger"; /** * Custom error class for fetch operations. @@ -60,7 +61,11 @@ const deleteSourceServer = async ( // We fetch it first before deleting to satisfy the return type if needed. const source = await sourceRepo.findById(mediaSourceId); await sourceRepo.delete(mediaSourceId); - await ccipVectorService.deleteBySource(mediaSourceId); + try { + await ccipVectorService.deleteBySource(mediaSourceId); + } catch (err) { + logger.warn({ err, mediaSourceId }, "Failed to delete CCIP vectors during source deletion"); + } return source ? [source] : []; }; diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index 82997361..399589bc 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -96,25 +96,22 @@ export function MediaSidebar(props: MediaSidebarProps) { >("missing"); const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); - const [ccipStatusRequestId, setCcipStatusRequestId] = createSignal(0); + let ccipAbortController: AbortController | null = null; const refreshCcipStatus = async () => { - const requestId = ccipStatusRequestId() + 1; - setCcipStatusRequestId(requestId); + ccipAbortController?.abort(); + ccipAbortController = new AbortController(); + const { signal } = ccipAbortController; try { const result = await getCcipVectorStatus( props.media.mediaSourceId, props.media.id, ); - if (ccipStatusRequestId() !== requestId) { - return; - } + if (signal.aborted) return; setCcipStatus(result.status); setActiveCcipJobId(result.jobId ?? null); } catch { - if (ccipStatusRequestId() !== requestId) { - return; - } + if (signal.aborted) return; setCcipStatus("failed"); setActiveCcipJobId(null); } @@ -130,16 +127,19 @@ export function MediaSidebar(props: MediaSidebarProps) { const handleCcipExtraction = async () => { setIsExtractingCcip(true); + const currentMediaId = props.media.id; try { const result = await startCcipExtraction( props.media.mediaSourceId, - props.media.id, + currentMediaId, ccipStatus() === "ready" || ccipStatus() === "stale", ); + if (props.media.id !== currentMediaId) return; setCcipStatus("processing"); setActiveCcipJobId(result.jobId); toast.success("CCIP vector extraction queued"); } catch (error) { + if (props.media.id !== currentMediaId) return; toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); } finally { setIsExtractingCcip(false); diff --git a/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts b/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts index 88df102e..0e2f5639 100644 --- a/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts +++ b/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts @@ -58,16 +58,24 @@ export class LanceDbCcipVectorStore implements ICcipVectorStore { private async connection(): Promise { if (!this.connectionPromise) { - this.connectionPromise = import("@lancedb/lancedb").then((lancedb) => - lancedb.connect(path.resolve(process.cwd(), this.directory)), - ); + this.connectionPromise = import("@lancedb/lancedb") + .then((lancedb) => + lancedb.connect(path.resolve(process.cwd(), this.directory)), + ) + .catch((err) => { + this.connectionPromise = null; + throw err; + }); } return await this.connectionPromise; } private async table(): Promise
{ if (!this.tablePromise) { - this.tablePromise = this.openOrCreateTable(); + this.tablePromise = this.openOrCreateTable().catch((err) => { + this.tablePromise = null; + throw err; + }); } return await this.tablePromise; } diff --git a/apps/server/src/infrastructure/ai/rust-ai-client.ts b/apps/server/src/infrastructure/ai/rust-ai-client.ts index 6a97ffee..2358c190 100644 --- a/apps/server/src/infrastructure/ai/rust-ai-client.ts +++ b/apps/server/src/infrastructure/ai/rust-ai-client.ts @@ -2,6 +2,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { createClient } from "@solid-imager/client"; import type { IAiClient } from "@solid-imager/core/domain/interfaces/ai-client"; +import { asyncPool } from "@solid-imager/core/utils/async-pool"; import { type CcipDifferenceResponse, type CcipFeatureResponse, @@ -278,11 +279,13 @@ export class RustAiClient implements IAiClient { return await nativeModule.ccipDistances(feature, candidates); } } - return await Promise.all( - candidates.map(async (candidate) => { - const result = await this.calculateCcipDifference(feature, candidate); - return result.difference; - }), - ); + const settledResults = await asyncPool(candidates, 50, async (candidate) => { + const result = await this.calculateCcipDifference(feature, candidate); + return result.difference; + }); + return settledResults.map((r) => { + if (r.status === "fulfilled") return r.value; + throw r.reason; + }); } } diff --git a/apps/server/src/infrastructure/api/routers/ai-router.ts b/apps/server/src/infrastructure/api/routers/ai-router.ts index 1c143086..4116a920 100644 --- a/apps/server/src/infrastructure/api/routers/ai-router.ts +++ b/apps/server/src/infrastructure/api/routers/ai-router.ts @@ -499,6 +499,7 @@ export const aiRouter = { where: and( inArray(medias.id, input.mediaIds), eq(medias.mediaType, "image"), + input.mediaSourceId ? eq(medias.mediaSourceId, input.mediaSourceId) : undefined, ), columns: { id: true, mediaSourceId: true }, }); diff --git a/apps/server/src/infrastructure/api/routers/media-router.ts b/apps/server/src/infrastructure/api/routers/media-router.ts index 20f7982d..3f70cdae 100644 --- a/apps/server/src/infrastructure/api/routers/media-router.ts +++ b/apps/server/src/infrastructure/api/routers/media-router.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import { BulkOperationService } from "~/application/services/bulk-operation-service"; import { ccipVectorService } from "~/application/services/ccip-vector-service"; import { MediaService } from "~/application/services/media-service"; +import { logger } from "~/infrastructure/logger"; /** * Media Router Implementation @@ -200,7 +201,11 @@ export const mediaRouter = { ) .handler(async ({ input }) => { await MediaService.deleteMedia(input.sourceId, input.mediaId); - await ccipVectorService.delete(input.mediaId); + try { + await ccipVectorService.delete(input.mediaId); + } catch (err) { + logger.warn({ err, mediaId: input.mediaId }, "[MediaRouter] Vector delete failed after media delete"); + } return { success: true }; }), @@ -234,7 +239,11 @@ export const mediaRouter = { input.mediaId, input.targetSourceId, ); - await ccipVectorService.delete(input.mediaId); + try { + await ccipVectorService.delete(input.mediaId); + } catch (err) { + logger.warn({ err, mediaId: input.mediaId }, "[MediaRouter] Vector delete failed after media move"); + } return result; }), @@ -289,8 +298,14 @@ export const mediaRouter = { input.mediaSourceId, input.mediaIds, ); - await Promise.all( - input.mediaIds.map((mediaId) => ccipVectorService.delete(mediaId)), + await Promise.allSettled( + input.mediaIds.map(async (mediaId) => { + try { + await ccipVectorService.delete(mediaId); + } catch (err) { + logger.warn({ err, mediaId }, "[MediaRouter] Vector delete failed after bulk media delete"); + } + }), ); return { success: true }; }), diff --git a/packages/application/src/services/ccip-vector-service.ts b/packages/application/src/services/ccip-vector-service.ts index 5ceba9e4..97a39d6d 100644 --- a/packages/application/src/services/ccip-vector-service.ts +++ b/packages/application/src/services/ccip-vector-service.ts @@ -33,7 +33,7 @@ export class CcipVectorService { ): Promise<{ record: CcipVectorRecord; skipped: boolean }> { const media = await this.requireImage(mediaSourceId, mediaId); const existing = await this.deps.vectorStore.get(mediaId); - if (!force && existing && this.isCurrent(existing, media)) { + if (!force && existing && this.isCurrent(existing, media, mediaSourceId)) { return { record: existing, skipped: true }; } @@ -66,7 +66,7 @@ export class CcipVectorService { const record = await this.deps.vectorStore.get(mediaId); if (!record) return { status: "missing" }; return { - status: this.isCurrent(record, media) ? "ready" : "stale", + status: this.isCurrent(record, media, mediaSourceId) ? "ready" : "stale", model: record.model, extractedAt: record.extractedAt, }; @@ -96,7 +96,7 @@ export class CcipVectorService { const anchorMedia = await this.deps.mediaRepository.findById(anchorMediaId); if (!anchorMedia) throw new Error(`Media not found: ${anchorMediaId}`); const anchor = await this.deps.vectorStore.get(anchorMediaId); - if (!anchor || !this.isCurrent(anchor, anchorMedia)) { + if (!anchor || !this.isCurrent(anchor, anchorMedia, anchorMedia.mediaSourceId)) { throw new Error("CCIP vector is missing or stale for the anchor media"); } @@ -120,7 +120,7 @@ export class CcipVectorService { const mediaById = new Map(media.map((item) => [item.id, item])); const currentCandidates = candidates.filter((candidate) => { const item = mediaById.get(candidate.mediaId); - return item ? this.isCurrent(candidate, item) : false; + return item ? this.isCurrent(candidate, item, item.mediaSourceId) : false; }); if (currentCandidates.length === 0) { return { media: [], total: 0, scores: [] }; @@ -156,10 +156,11 @@ export class CcipVectorService { }; } - private isCurrent(record: CcipVectorRecord, media: Media): boolean { + private isCurrent(record: CcipVectorRecord, media: Media, mediaSourceId: string): boolean { return ( record.model === CCIP_MODEL && record.embeddingVersion === CCIP_EMBEDDING_VERSION && + record.mediaSourceId === mediaSourceId && record.mediaModifiedAt.getTime() === media.modifiedAt.getTime() ); } diff --git a/packages/application/src/services/media-processing-service.ts b/packages/application/src/services/media-processing-service.ts index b8982d43..a0e40819 100644 --- a/packages/application/src/services/media-processing-service.ts +++ b/packages/application/src/services/media-processing-service.ts @@ -271,7 +271,7 @@ export class MediaProcessingServiceImpl implements IMediaProcessingService { if (this.enableAutoCcipExtraction && media.mediaType === "image") { try { - await this.jobRepo.create({ + await this.jobRepo.createIfUnique({ type: "extract_ccip_vector", mediaSourceId, payload: { diff --git a/packages/core/src/domain/contract/ai.contract.ts b/packages/core/src/domain/contract/ai.contract.ts index a5be21ab..962e2ae2 100644 --- a/packages/core/src/domain/contract/ai.contract.ts +++ b/packages/core/src/domain/contract/ai.contract.ts @@ -1,6 +1,6 @@ import { oc } from "@orpc/contract"; import { z } from "zod"; -import { mediaSchema } from "../media/schemas"; +import { mediaSafeSchema } from "../media/schemas"; import { batchCcipExtractionRequestSchema, batchTaggingRequestSchema, @@ -52,7 +52,7 @@ export const aiContract = { scanBatchCcipTargets: oc .input(batchCcipExtractionRequestSchema) - .output(z.array(mediaSchema)), + .output(z.array(mediaSafeSchema)), startBatchCcipExtraction: oc .input( @@ -64,7 +64,7 @@ export const aiContract = { scanBatchTaggingTargets: oc .input(batchTaggingRequestSchema) - .output(z.array(mediaSchema)), + .output(z.array(mediaSafeSchema)), batchTagging: oc .input(batchTaggingRequestSchema) diff --git a/packages/core/src/domain/media/schemas.ts b/packages/core/src/domain/media/schemas.ts index f303e955..ddc68ca2 100644 --- a/packages/core/src/domain/media/schemas.ts +++ b/packages/core/src/domain/media/schemas.ts @@ -154,6 +154,9 @@ export const mediaSchema = z.object({ export type Media = z.infer; +export const mediaSafeSchema = mediaSchema.omit({ filePath: true }); +export type MediaSafe = z.infer; + export const authorSchema = z.object({ id: z.uuid({ version: "v4" }), name: z.string(), diff --git a/packages/core/src/domain/search/logic.ts b/packages/core/src/domain/search/logic.ts index c8902cbd..81be3d20 100644 --- a/packages/core/src/domain/search/logic.ts +++ b/packages/core/src/domain/search/logic.ts @@ -13,7 +13,8 @@ export const calculateNextModeState = ( nextMode: "simple" | "pro" | "vector", ): Partial => { if (nextMode === "vector") { - return { mode: "vector", offset: 0, scrollY: 0 }; + const condition = getSearchConditionFromState(currentState); + return { mode: "vector", offset: 0, scrollY: 0, advancedCondition: condition || null }; } if (nextMode === "pro") { // Switching from simple to pro: populate advancedCondition from current simple filters diff --git a/packages/core/src/domain/tagging/schemas.ts b/packages/core/src/domain/tagging/schemas.ts index b246fb43..5e775c51 100644 --- a/packages/core/src/domain/tagging/schemas.ts +++ b/packages/core/src/domain/tagging/schemas.ts @@ -40,8 +40,8 @@ export const ccipDifferenceRequestSchema = z.object({ }); export const ccipDistancesRequestSchema = z.object({ - feature: z.array(z.number()), - candidates: z.array(z.array(z.number())), + feature: z.array(z.number()).length(768), + candidates: z.array(z.array(z.number()).length(768)), }); export const ccipDistancesResponseSchema = z.object({ diff --git a/packages/ui/src/media-sidebar.tsx b/packages/ui/src/media-sidebar.tsx index 27cffb4d..074a4fc0 100644 --- a/packages/ui/src/media-sidebar.tsx +++ b/packages/ui/src/media-sidebar.tsx @@ -136,14 +136,17 @@ export function MediaSidebar(props: MediaSidebarProps) { const extractCcipVector = async () => { if (!props.startCcipExtraction) return; setIsExtractingCcip(true); + const currentMediaId = props.media.id; try { const result = await props.startCcipExtraction( ccipStatus() === "ready" || ccipStatus() === "stale", ); + if (props.media.id !== currentMediaId) return; setCcipStatus("processing"); setActiveCcipJobId(result.jobId); toast.success("CCIP vector extraction queued"); } catch (error) { + if (props.media.id !== currentMediaId) return; toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); } finally { setIsExtractingCcip(false); From 3cda69ecd028cc43ce8c9d95cf2cc88deaf165ac Mon Sep 17 00:00:00 2001 From: hmjn Date: Mon, 29 Jun 2026 22:44:59 +0900 Subject: [PATCH 6/6] fix(ccip): resolve PR review blockers --- .../src/components/media/media-card-item.tsx | 4 +- .../src/components/media/media-sidebar.tsx | 21 ++++++++--- .../src/components/media/thumbnail-image.tsx | 4 +- apps/server/src/routes/manager.tsx | 4 +- .../src/components/media/media-card-item.tsx | 4 +- .../src/components/media/thumbnail-image.tsx | 4 +- apps/tauri/src/routes/manager.tsx | 4 +- packages/core/src/domain/media/schemas.ts | 15 +++++++- packages/ui/src/hooks/use-manager-page.ts | 37 +++++++++++++++---- packages/ui/src/media-card-item.tsx | 28 ++++++++------ packages/ui/src/screens/manager-screen.tsx | 4 +- 11 files changed, 90 insertions(+), 39 deletions(-) diff --git a/apps/server/src/components/media/media-card-item.tsx b/apps/server/src/components/media/media-card-item.tsx index ea1e225f..4e5edaee 100644 --- a/apps/server/src/components/media/media-card-item.tsx +++ b/apps/server/src/components/media/media-card-item.tsx @@ -1,10 +1,10 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { MediaCardItem as SharedMediaCardItem } from "@solid-imager/ui/media-card-item"; import { Link } from "@tanstack/solid-router"; import { ThumbnailImage } from "./thumbnail-image"; type MediaCardItemProps = { - media: Media; + media: MediaSafe; selectable?: boolean; selected?: boolean; onToggle?: (id: string) => void; diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index 399589bc..420ebde6 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -11,6 +11,7 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { AiTaggingModal } from "~/components/media/ai-tagging-modal"; import AssociationManager from "~/components/media/association-manager"; import CharacterCropModal from "~/components/media/character-crop-modal"; +import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; import { getCcipVectorStatus, startCcipExtraction, @@ -37,7 +38,6 @@ import { allProjectsQueryOptions, projectsForMediaQueryOptions, } from "~/infrastructure/api-clients/queries"; -import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; type MediaSidebarProps = { media: MediaDetails; @@ -94,7 +94,9 @@ export function MediaSidebar(props: MediaSidebarProps) { const [ccipStatus, setCcipStatus] = createSignal< "missing" | "processing" | "ready" | "stale" | "failed" >("missing"); - const [activeCcipJobId, setActiveCcipJobId] = createSignal(null); + const [activeCcipJobId, setActiveCcipJobId] = createSignal( + null, + ); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); let ccipAbortController: AbortController | null = null; @@ -120,6 +122,7 @@ export function MediaSidebar(props: MediaSidebarProps) { createEffect(() => { props.media.id; props.media.mediaSourceId; + setIsExtractingCcip(false); setCcipStatus("missing"); setActiveCcipJobId(null); void refreshCcipStatus(); @@ -128,21 +131,27 @@ export function MediaSidebar(props: MediaSidebarProps) { const handleCcipExtraction = async () => { setIsExtractingCcip(true); const currentMediaId = props.media.id; + const currentMediaSourceId = props.media.mediaSourceId; + const isCurrentMedia = () => + props.media.id === currentMediaId && + props.media.mediaSourceId === currentMediaSourceId; try { const result = await startCcipExtraction( - props.media.mediaSourceId, + currentMediaSourceId, currentMediaId, ccipStatus() === "ready" || ccipStatus() === "stale", ); - if (props.media.id !== currentMediaId) return; + if (!isCurrentMedia()) return; setCcipStatus("processing"); setActiveCcipJobId(result.jobId); toast.success("CCIP vector extraction queued"); } catch (error) { - if (props.media.id !== currentMediaId) return; + if (!isCurrentMedia()) return; toast.error(`Failed to extract CCIP vector: ${getErrorMessage(error)}`); } finally { - setIsExtractingCcip(false); + if (isCurrentMedia()) { + setIsExtractingCcip(false); + } } }; diff --git a/apps/server/src/components/media/thumbnail-image.tsx b/apps/server/src/components/media/thumbnail-image.tsx index d215aef1..103d9dc1 100644 --- a/apps/server/src/components/media/thumbnail-image.tsx +++ b/apps/server/src/components/media/thumbnail-image.tsx @@ -1,4 +1,4 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { ThumbnailImage as SharedThumbnailImage } from "@solid-imager/ui/thumbnail-image"; import { type BuildThumbnailUrlArgs, @@ -13,7 +13,7 @@ type ThumbnailImageProps = { height?: number | null; loading?: "eager" | "lazy"; maxRetries?: number; - media: Media; + media: MediaSafe; retryDelayMs?: number; sourceRootPath?: string; width?: number | null; diff --git a/apps/server/src/routes/manager.tsx b/apps/server/src/routes/manager.tsx index cb2e5ca9..08252b10 100644 --- a/apps/server/src/routes/manager.tsx +++ b/apps/server/src/routes/manager.tsx @@ -1,4 +1,4 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { prefetchManagerPageQueries, useManagerPage, @@ -86,7 +86,7 @@ function ManagerPage() { void, ) => ( diff --git a/apps/tauri/src/components/media/media-card-item.tsx b/apps/tauri/src/components/media/media-card-item.tsx index ea1e225f..4e5edaee 100644 --- a/apps/tauri/src/components/media/media-card-item.tsx +++ b/apps/tauri/src/components/media/media-card-item.tsx @@ -1,10 +1,10 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { MediaCardItem as SharedMediaCardItem } from "@solid-imager/ui/media-card-item"; import { Link } from "@tanstack/solid-router"; import { ThumbnailImage } from "./thumbnail-image"; type MediaCardItemProps = { - media: Media; + media: MediaSafe; selectable?: boolean; selected?: boolean; onToggle?: (id: string) => void; diff --git a/apps/tauri/src/components/media/thumbnail-image.tsx b/apps/tauri/src/components/media/thumbnail-image.tsx index ba0cbea4..5aacf53c 100644 --- a/apps/tauri/src/components/media/thumbnail-image.tsx +++ b/apps/tauri/src/components/media/thumbnail-image.tsx @@ -1,4 +1,4 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { ThumbnailImage as SharedThumbnailImage, type ThumbnailImageProps as SharedThumbnailImageProps, @@ -16,7 +16,7 @@ type ThumbnailImageProps = { height?: number | null; loading?: "eager" | "lazy"; maxRetries?: number; - media: Media; + media: MediaSafe; retryDelayMs?: number; sourceRootPath?: string; width?: number | null; diff --git a/apps/tauri/src/routes/manager.tsx b/apps/tauri/src/routes/manager.tsx index de1fb024..e11f49fa 100644 --- a/apps/tauri/src/routes/manager.tsx +++ b/apps/tauri/src/routes/manager.tsx @@ -1,4 +1,4 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import { prefetchManagerPageQueries, useManagerPage, @@ -90,7 +90,7 @@ function ManagerPage() { void, ) => ( diff --git a/packages/core/src/domain/media/schemas.ts b/packages/core/src/domain/media/schemas.ts index ddc68ca2..06c0e675 100644 --- a/packages/core/src/domain/media/schemas.ts +++ b/packages/core/src/domain/media/schemas.ts @@ -154,7 +154,20 @@ export const mediaSchema = z.object({ export type Media = z.infer; -export const mediaSafeSchema = mediaSchema.omit({ filePath: true }); +export const mediaSafeSchema = mediaSchema.pick({ + id: true, + mediaSourceId: true, + fileName: true, + mediaType: true, + width: true, + height: true, + fileSize: true, + description: true, + createdAt: true, + modifiedAt: true, + indexedAt: true, + status: true, +}); export type MediaSafe = z.infer; export const authorSchema = z.object({ diff --git a/packages/ui/src/hooks/use-manager-page.ts b/packages/ui/src/hooks/use-manager-page.ts index 46646d9d..0dbcd6a2 100644 --- a/packages/ui/src/hooks/use-manager-page.ts +++ b/packages/ui/src/hooks/use-manager-page.ts @@ -2,7 +2,7 @@ import type { Character } from "@solid-imager/core/domain/characters/schemas"; import type { Ip } from "@solid-imager/core/domain/ips/schemas"; import type { DuplicateGroup, - Media, + MediaSafe, } from "@solid-imager/core/domain/media/schemas"; import type { Project } from "@solid-imager/core/domain/projects/schemas"; import type { @@ -68,7 +68,7 @@ export type ManagerPageActions = { scanBatchTaggingTargets: (input: { force: boolean; mediaSourceId?: string; - }) => Promise; + }) => Promise; startBatchTaggingWithIds: (input: { force: boolean; mediaSourceId?: string; @@ -77,7 +77,7 @@ export type ManagerPageActions = { scanBatchCcipTargets: (input: { force: boolean; mediaSourceId?: string; - }) => Promise; + }) => Promise; startBatchCcipExtraction: (input: { force: boolean; mediaSourceId?: string; @@ -152,7 +152,7 @@ export type UseManagerPageResult = { forceRetag: Accessor; setForceRetag: Setter; taggingStatus: Accessor; - scannedMedia: Accessor; + scannedMedia: Accessor; selectedMedia: Accessor>; jobProgress: Accessor; activeJobId: Accessor; @@ -160,7 +160,7 @@ export type UseManagerPageResult = { setCurrentPage: Setter; itemsPerPage: number; totalPages: Accessor; - paginatedMedia: Accessor; + paginatedMedia: Accessor; sources: Accessor; ips: Accessor; getActiveItems: Accessor; @@ -261,7 +261,7 @@ export function useManagerPage( >(undefined); const [forceRetag, setForceRetag] = createSignal(false); const [taggingStatus, setTaggingStatus] = createSignal(null); - const [scannedMedia, setScannedMedia] = createSignal([]); + const [scannedMedia, setScannedMedia] = createSignal([]); const [selectedMedia, setSelectedMedia] = createSignal>( new Set(), ); @@ -271,6 +271,18 @@ export function useManagerPage( const [activeJobId, setActiveJobId] = createSignal(null); const [currentPage, setCurrentPage] = createSignal(1); + createEffect(() => { + const tab = activeTab(); + if (tab !== "tagging" && tab !== "vectors") { + return; + } + + setScannedMedia([]); + setSelectedMedia(new Set()); + setTaggingStatus(null); + setCurrentPage(1); + }); + // Duplicates state const [duplicateSourceId, setDuplicateSourceId] = createSignal< string | undefined @@ -411,11 +423,16 @@ export function useManagerPage( }; const handleScan = async () => { + const scanTab = activeTab(); + if (scanTab !== "tagging" && scanTab !== "vectors") { + return; + } + try { setTaggingStatus("Scanning..."); setScannedMedia([]); const result = - activeTab() === "vectors" + scanTab === "vectors" ? await actions.scanBatchCcipTargets({ force: forceRetag(), mediaSourceId: selectedSourceId(), @@ -424,10 +441,16 @@ export function useManagerPage( force: forceRetag(), mediaSourceId: selectedSourceId(), }); + if (activeTab() !== scanTab) { + return; + } setScannedMedia(result); setSelectedMedia(new Set(result.map((item) => item.id))); setTaggingStatus(`${result.length} items found.`); } catch (error) { + if (activeTab() !== scanTab) { + return; + } toast.error(`Error: ${getErrorMessage(error)}`); setTaggingStatus(`Error during scan: ${getErrorMessage(error)}`); } diff --git a/packages/ui/src/media-card-item.tsx b/packages/ui/src/media-card-item.tsx index 5fcc3c39..4049915b 100644 --- a/packages/ui/src/media-card-item.tsx +++ b/packages/ui/src/media-card-item.tsx @@ -1,16 +1,20 @@ -import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { MediaSafe } from "@solid-imager/core/domain/media/schemas"; import type { JSX } from "solid-js"; import { Show } from "solid-js"; import { Card } from "./card"; import { Checkbox, CheckboxControl, CheckboxLabel } from "./checkbox"; import { cn } from "./utils/cn"; +export type MediaCardMedia = MediaSafe & { + filePath?: string; +}; + export type MediaCardThumbnailProps = { alt: string; class: string; height?: number | null; loading: "eager" | "lazy"; - media: Media; + media: MediaCardMedia; sourceRootPath?: string; width?: number | null; }; @@ -23,13 +27,13 @@ export type MediaCardLinkProps = { }; type MediaCardItemProps = { - media: Media; + media: MediaCardMedia; selectable?: boolean; isSelected?: boolean; onSelect?: (id: string) => void; priority?: boolean; sourceRootPath?: string; - canRenderThumbnail?: (media: Media) => boolean; + canRenderThumbnail?: (media: MediaCardMedia) => boolean; linkComponent?: (props: MediaCardLinkProps) => JSX.Element; renderThumbnail: (props: MediaCardThumbnailProps) => JSX.Element; class?: string; @@ -46,7 +50,7 @@ function formatFileSize(bytes: number | null | undefined) { return `${(bytes / 1024).toFixed(1)} KB`; } -function formatDimensions(media: Media, separator: string) { +function formatDimensions(media: MediaCardMedia, separator: string) { return media.width && media.height ? `${media.width}${separator}${media.height}` : "N/A"; @@ -123,12 +127,14 @@ export function MediaCardItem(props: MediaCardItemProps) {

{props.media.fileName}

-

- {props.media.filePath} -

+ +

+ {props.media.filePath} +

+
{formatDimensions( diff --git a/packages/ui/src/screens/manager-screen.tsx b/packages/ui/src/screens/manager-screen.tsx index f4b186fd..2fd3a376 100644 --- a/packages/ui/src/screens/manager-screen.tsx +++ b/packages/ui/src/screens/manager-screen.tsx @@ -1,7 +1,7 @@ import type { Ip } from "@solid-imager/core/domain/ips/schemas"; import type { DuplicateGroup, - Media, + MediaSafe, } from "@solid-imager/core/domain/media/schemas"; import type { JSX } from "solid-js"; import { For, Show } from "solid-js"; @@ -62,7 +62,7 @@ import { export type ManagerScreenProps = { manager: UseManagerPageResult; renderMediaCard: ( - media: Media, + media: MediaSafe, selected: boolean, onToggle: (mediaId: string) => void, ) => JSX.Element;