diff --git a/.env.local.example b/.env.local.example index 7340c1a..1307872 100644 --- a/.env.local.example +++ b/.env.local.example @@ -39,8 +39,9 @@ DASHBOARD_ORIGIN=http://dashboard.local.knowhereto.ai:3000 # local: http://notebook.local.knowhereto.ai:3001 NOTEBOOK_PUBLIC_URL=http://notebook.local.knowhereto.ai:3001 -# Optional override of the session cookie names Dashboard sets. Defaults -# to Better Auth's standard cookie names. +# Optional additional session cookie names Dashboard sets. +# Without an override, Notebook recognizes Better Auth session-token cookies +# with standard or environment-prefixed names. # SESSION_COOKIE_NAMES=better-auth.session_token,__Secure-better-auth.session_token # --- Product analytics (client-side) --- diff --git a/.gitignore b/.gitignore index 74889db..2a2a9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ # misc .DS_Store *.pem +.repos/effect # debug npm-debug.log* diff --git a/docs/parsed-results-citation-ux.md b/docs/parsed-results-citation-ux.md new file mode 100644 index 0000000..4730107 --- /dev/null +++ b/docs/parsed-results-citation-ux.md @@ -0,0 +1,72 @@ +# Parsed Results + citation UX + +Recorded 2026-08-18 so we do not relitigate the Figma / issue discussion. + +Sources: + +- Figma [Knowhere (Copy) / brain](https://www.figma.com/design/0jjj7SQ1N0zo4D9WzJoNJh/Knowhere--Copy-?node-id=4000-37742) (`4000:37742`) +- GitHub [knowhere#222](https://github.com/Ontos-AI/knowhere/issues/222), [knowhere#223](https://github.com/Ontos-AI/knowhere/issues/223) +- Product lock from that conversation (issues and Figma notes are partly stale) + +This work lands in **Knowhere Notebook**. Region geometry later lands in **Knowhere retrieval** (`Ontos-AI/knowhere`) and is only consumed here when the payload exists. + +## Locked product + +Vision / page-image mode only. There is **no original PDF preview** as a citation destination. + +| Topic | Decision | +| --- | --- | +| Layout | Sources \| Parsed Results \| Assistant (today’s Notebook columns). Ignore the Figma frame that put Assistant in the middle. | +| Rename | Knowhere Notebook → **Knowhere Brain** | +| Canvas | Title **Parsed Results**, subtitle **From {source title}** | +| Page card | **Page N + parse path**, page image, keywords. No keywords → hide the keyword row, still show the image. Screen 2 “text” is a crop overlay on the page image, not a text chunk. | +| List / Tree | Keep **both**. Tree is the **existing** section tree, also in page-image mode. **Default Tree**. Citation click may switch to List so the page card is visible. | +| Inline chips | One chip **per citation**, not per page. Label `{source title}/pN` (same name as the Sources row). Max width 250px, truncate. Same page can be two chips; later they get different crop boxes. | +| Chip / `pN` click | Switch the **viewed** source if needed (not query checkboxes), open that file’s parsed page, scroll to it. Chat may cite any workspace file while the canvas shows another. | +| Footer SOURCES | Numbered list, one row per file, `{title}` + cited page links `p25 p26 p27`. Those links do the **same jump** as chips. | +| Copy / download | Copy answer / code / tables. Download **this assistant turn** as **Markdown and PDF**, **without citations** (no chips, no SOURCES list). | +| Region box | **Required product**, **not this slice**. When retrieval sends a crop, draw `#8E51FF` / 25% / 8px radius on that page image (flash twice, 3s, fade). Until then, page jump only. Do not fake a full-page box. | + +Out of this slice: original-file viewer for citations, region overlay, faking geometry. + +## Current code (gaps) + +Almost all of this is UI in this repo. Page images, keywords, page numbers, and source-title chips already exist in pieces. + +| Area | Today | Need | +| --- | --- | --- | +| Branding | “Knowhere Notebook” in `top-nav.tsx`, `lib/app-metadata.ts`, login, `global-error.tsx` | Brain | +| Canvas copy | “Parsed Chunks” / “Showing all parsed chunks from …” | Parsed Results / From {title} | +| Original preview | Hidden for `page-assets` sources (`chunks-panel.tsx`) | Keep hidden. Citations must not reopen it. | +| List / Tree | Tree **hidden** for page-asset sources (`!isPageAssetSource`). Default mode is already `"tree"` but unused in page mode. | Show the toggle for page mode; Tree uses existing `ChunkSectionTree`. | +| Page cards | `PageChunkCard` already shows page image + `ChunkKeywords` | Match Figma title (`Page N` + path). Image-first. Hide keywords when empty (already returns null). | +| Inline chips | `[Source N: …]` tokens are **stripped** (`buildCitationContentMarkdown`). Chips live in a footer `AssistantSources`. | Replace tokens with inline `{title}/pN` chips in the answer. | +| Chip label | Filename / source title only, **no `/pN`**. | `{title}/pN` | +| Dedupe | `getDisplayCitations` and `selectCitationRawResults` drop same-key citations. | Keep one chip per citation so two regions on one page stay two chips. | +| Footer SOURCES | Flat chips | Numbered per-file list + page links | +| Click target | `handleCitationClick` focuses a **chunk**. `focusedPage` is plumbed to `ChunksPanel` but **not used**. | Focus/scroll the **page** card; `onSelectSource` for file B while viewing A already exists. | +| Export | None | Per-turn copy + MD/PDF without citations | + +Later API (do not block Notebook UI): + +- Retrieval / agent citation payload has page number and page image today (`pageCitationPageNumber`, `pageCitationAssetUrl`). +- It does **not** have a citation crop box. Worker bboxes are for asset crops, not “this quote lives here.” +- When Knowhere adds a region on the citation, extend `ChatCitationView` + persistence, then overlay on `PageCitationAssetImage`. + +## Implementation slices (Notebook) + +Do these in order. Tests follow existing ownership: model tests for labels/grouping, component tests for chips/cards/export, panel tests for click → source + page. + +1. **Chrome** — rename Brain; Parsed Results header/subtitle; enable List/Tree on page-asset sources; default Tree. +2. **Page cards** — Figma card header (page + path); image + optional keywords; citation click forces List (already does via `citationListViewRequestId`) and scrolls to the page chunk. +3. **Citations** — stop stripping without replacement; inline chips `{title}/pN`; stop collapsing same-page citations; numbered SOURCES with `pN` links; click switches viewed source and page-focuses. +4. **Copy / download** — message actions: clipboard of visible answer (code/tables included as rendered); MD + PDF of this turn with the same citation-stripped body we already produce for display (`buildCitationContentMarkdown` / equivalent). PDF is client-side from that Markdown, not the original document. +5. **Later** — region overlay when retrieval sends a box. Keep citation identity stable (do not key chips by `title+page` only). + +## Do not + +- Route citation clicks to original PDF preview. +- Deduplicate inline chips by page. +- Draw a highlight without retrieval geometry. +- Change which sources are **checked for the next query** when jumping the canvas. +- Put this feature in archived `knowhere-api`; new retrieval fields go in `Ontos-AI/knowhere`. diff --git a/drizzle/0010_minor_piledriver.sql b/drizzle/0010_minor_piledriver.sql new file mode 100644 index 0000000..973029b --- /dev/null +++ b/drizzle/0010_minor_piledriver.sql @@ -0,0 +1,2 @@ +ALTER TABLE "source_parse_results" ADD COLUMN "snapshot_manifest_url" text;--> statement-breakpoint +ALTER TABLE "source_parse_results" ADD COLUMN "snapshot_manifest_key" text; \ No newline at end of file diff --git a/drizzle/0011_rare_deadpool.sql b/drizzle/0011_rare_deadpool.sql new file mode 100644 index 0000000..0cf5000 --- /dev/null +++ b/drizzle/0011_rare_deadpool.sql @@ -0,0 +1,5 @@ +ALTER TABLE "source_parse_results" ALTER COLUMN "result_blob_url" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "source_parse_results" ADD COLUMN "revision_key" text;--> statement-breakpoint +ALTER TABLE "source_parse_results" ADD COLUMN "sync_status" text;--> statement-breakpoint +ALTER TABLE "source_parse_results" ADD COLUMN "sync_error" text;--> statement-breakpoint +ALTER TABLE "sources" ADD COLUMN "failure_stage" text; \ No newline at end of file diff --git a/drizzle/0012_lazy_wendell_rand.sql b/drizzle/0012_lazy_wendell_rand.sql new file mode 100644 index 0000000..f50ab54 --- /dev/null +++ b/drizzle/0012_lazy_wendell_rand.sql @@ -0,0 +1,21 @@ +CREATE TABLE "parsed_document_sync_leases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "source_id" uuid NOT NULL, + "document_id" text NOT NULL, + "revision_key" text, + "lease_token" text NOT NULL, + "acquired_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "released_at" timestamp with time zone, + "release_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "parsed_document_sync_leases" ADD CONSTRAINT "parsed_document_sync_leases_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "parsed_document_sync_leases" ADD CONSTRAINT "parsed_document_sync_leases_source_id_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "parsed_document_sync_leases_token_idx" ON "parsed_document_sync_leases" USING btree ("lease_token");--> statement-breakpoint +CREATE INDEX "parsed_document_sync_leases_active_idx" ON "parsed_document_sync_leases" USING btree ("expires_at") WHERE released_at IS NULL;--> statement-breakpoint +CREATE INDEX "parsed_document_sync_leases_workspace_active_idx" ON "parsed_document_sync_leases" USING btree ("workspace_id") WHERE released_at IS NULL;--> statement-breakpoint +CREATE INDEX "parsed_document_sync_leases_document_active_idx" ON "parsed_document_sync_leases" USING btree ("document_id") WHERE released_at IS NULL; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..b9b5a4f --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,728 @@ +{ + "id": "cc41c815-4e52-4d01-ac2e-abbc791331b7", + "prevId": "c59ee2f8-6b59-4a2c-98a3-d71a67388d47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_manifest_url": { + "name": "snapshot_manifest_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_key": { + "name": "snapshot_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_document_idx": { + "name": "sources_workspace_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowhere_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "knowhere_document_id IS NOT NULL AND deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..0f8448c --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,752 @@ +{ + "id": "bab84445-8eb2-4d15-856e-b2f59edcaf47", + "prevId": "cc41c815-4e52-4d01-ac2e-abbc791331b7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_url": { + "name": "snapshot_manifest_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_key": { + "name": "snapshot_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_key": { + "name": "revision_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_document_idx": { + "name": "sources_workspace_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowhere_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "knowhere_document_id IS NOT NULL AND deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..4011068 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,932 @@ +{ + "id": "a2a2f9a9-d567-4413-89d8-fa714b994351", + "prevId": "bab84445-8eb2-4d15-856e-b2f59edcaf47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_messages_thread_created_idx": { + "name": "chat_messages_thread_created_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_threads_workspace_updated_idx": { + "name": "chat_threads_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_threads_workspace_demo_key_idx": { + "name": "chat_threads_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.demo_source_visibilities": { + "name": "demo_source_visibilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "demo_source_id": { + "name": "demo_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "demo_source_visibilities_workspace_source_idx": { + "name": "demo_source_visibilities_workspace_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "demo_source_visibilities_workspace_idx": { + "name": "demo_source_visibilities_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "demo_source_visibilities_workspace_id_workspaces_id_fk": { + "name": "demo_source_visibilities_workspace_id_workspaces_id_fk", + "tableFrom": "demo_source_visibilities", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parsed_document_sync_leases": { + "name": "parsed_document_sync_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision_key": { + "name": "revision_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "release_reason": { + "name": "release_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "parsed_document_sync_leases_token_idx": { + "name": "parsed_document_sync_leases_token_idx", + "columns": [ + { + "expression": "lease_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_active_idx": { + "name": "parsed_document_sync_leases_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_workspace_active_idx": { + "name": "parsed_document_sync_leases_workspace_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "parsed_document_sync_leases_document_active_idx": { + "name": "parsed_document_sync_leases_document_active_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "released_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parsed_document_sync_leases_workspace_id_workspaces_id_fk": { + "name": "parsed_document_sync_leases_workspace_id_workspaces_id_fk", + "tableFrom": "parsed_document_sync_leases", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parsed_document_sync_leases_source_id_sources_id_fk": { + "name": "parsed_document_sync_leases_source_id_sources_id_fk", + "tableFrom": "parsed_document_sync_leases", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_parse_results": { + "name": "source_parse_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "result_blob_url": { + "name": "result_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_url": { + "name": "snapshot_manifest_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_manifest_key": { + "name": "snapshot_manifest_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_key": { + "name": "revision_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_urls": { + "name": "asset_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_parse_results_source_id_idx": { + "name": "source_parse_results_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_parse_results_source_id_sources_id_fk": { + "name": "source_parse_results_source_id_sources_id_fk", + "tableFrom": "source_parse_results", + "tableTo": "sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "source_parse_results_source_id_unique": { + "name": "source_parse_results_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sources": { + "name": "sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_job_id": { + "name": "knowhere_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowhere_document_id": { + "name": "knowhere_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_pathname": { + "name": "staged_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_blob_url": { + "name": "staged_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_pathname": { + "name": "original_blob_pathname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_blob_url": { + "name": "original_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_key": { + "name": "demo_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sources_workspace_created_idx": { + "name": "sources_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_status_idx": { + "name": "sources_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_demo_key_idx": { + "name": "sources_workspace_demo_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "demo_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sources_workspace_document_idx": { + "name": "sources_workspace_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowhere_document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "knowhere_document_id IS NOT NULL AND deleted_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sources_workspace_id_workspaces_id_fk": { + "name": "sources_workspace_id_workspaces_id_fk", + "tableFrom": "sources", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_user_id_idx": { + "name": "workspaces_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_user_id_unique": { + "name": "workspaces_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "workspaces_namespace_unique": { + "name": "workspaces_namespace_unique", + "nullsNotDistinct": false, + "columns": [ + "namespace" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 568b77f..93f0ced 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,27 @@ "when": 1782472980935, "tag": "0009_true_lila_cheney", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1783098230819, + "tag": "0010_minor_piledriver", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1783175614191, + "tag": "0011_rare_deadpool", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1783231408481, + "tag": "0012_lazy_wendell_rand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e/chunk-original-preview.e2e.ts b/e2e/chunk-original-preview.e2e.ts index 1a6502c..c7e0426 100644 --- a/e2e/chunk-original-preview.e2e.ts +++ b/e2e/chunk-original-preview.e2e.ts @@ -7,7 +7,7 @@ test("reopens the demo PDF preview after returning from original to parsed chunk }) => { await page.goto("/") await expect( - page.getByRole("heading", { name: "Parsed Chunks" }), + page.getByRole("heading", { name: "Parsed Results" }), ).toBeVisible() await openFirstChunk(page) @@ -18,7 +18,7 @@ test("reopens the demo PDF preview after returning from original to parsed chunk .getByRole("button", { name: "Parsed", exact: true }) .click() await expect( - page.getByRole("heading", { name: "Parsed Chunks" }), + page.getByRole("heading", { name: "Parsed Results" }), ).toBeVisible() await openFirstChunk(page) diff --git a/e2e/citation-dedupe.e2e.ts b/e2e/citation-dedupe.e2e.ts index 78de15e..03c3b1a 100644 --- a/e2e/citation-dedupe.e2e.ts +++ b/e2e/citation-dedupe.e2e.ts @@ -87,3 +87,155 @@ test("keeps duplicate source labels clickable for separate documents", async ({ await expect(page.getByText("First report source content.")).toBeVisible() expect(firstSourceChunkRequests).toBeGreaterThan(0) }) + +test("keeps two chips to the same title/pN as separate buttons", async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: "better-auth.session_token", + value: "playwright", + url: "http://localhost:3000", + }, + ]) + + await page.goto("/e2e/citation-same-page") + + const chatPanel = page.getByTestId("desktop-chat-panel") + await expect(chatPanel.getByTestId("citation-chip")).toHaveCount(2) + await expect( + chatPanel.getByRole("button", { name: "Open source spacex-s1.pdf/p26" }), + ).toHaveCount(2) +}) + +test("draws citation regions instead of a full-page highlight", async ({ + context, + page, +}) => { + await context.addCookies([ + { + name: "better-auth.session_token", + value: "playwright", + url: "http://localhost:3000", + }, + ]) + await page.setViewportSize({ width: 1280, height: 832 }) + await page.route("**/api/sources/source_spacex/chunks**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + chunks: [ + { + chunkId: "page_26", + documentId: "doc_spacex", + sectionPath: "Page 26", + type: "page", + content: "Revenue evidence on page 26.", + readableContent: "Revenue evidence on page 26.", + pageNums: [26], + pageAssets: [ + { + pageNumber: 26, + assetUrl: "/images/knowhere/logo-icon.png", + contentType: "image/png", + width: 1000, + height: 1400, + }, + ], + sourceTitle: "spacex-s1.pdf", + }, + ], + pagination: { + page: 1, + pageSize: 50, + total: 1, + totalPages: 1, + }, + }), + }) + }) + + await page.goto("/e2e/citation-same-page") + const chips = page + .getByTestId("desktop-chat-panel") + .getByTestId("citation-chip") + await expect(chips).toHaveCount(2) + + await chips.first().click() + const firstRegions = page.getByTestId("citation-region-highlight") + await expect(firstRegions).toHaveCount(1) + await expect + .poll(() => + firstRegions.first().evaluate((element) => ({ + left: element.style.left, + top: element.style.top, + width: element.style.width, + height: element.style.height, + })), + ) + .toEqual({ left: "12%", top: "18%", width: "46%", height: "8%" }) + const pageImage = page.getByRole("img", { name: "Page 26" }) + await expect(pageImage).toBeVisible() + const imageBox = await pageImage.boundingBox() + const firstRegionBox = await firstRegions.first().boundingBox() + expect(imageBox).not.toBeNull() + expect(firstRegionBox).not.toBeNull() + expect(firstRegionBox!.x).toBeCloseTo( + imageBox!.x + imageBox!.width * 0.12, + 1, + ) + expect(firstRegionBox!.y).toBeCloseTo( + imageBox!.y + imageBox!.height * 0.18, + 1, + ) + expect(firstRegionBox!.width).toBeCloseTo(imageBox!.width * 0.46, 1) + expect(firstRegionBox!.height).toBeCloseTo(imageBox!.height * 0.08, 1) + + await chips.nth(1).click() + const secondRegions = page.getByTestId("citation-region-highlight") + await expect(secondRegions).toHaveCount(2) + const regionStyles = await secondRegions.evaluateAll((elements) => + elements.map((element) => ({ + left: (element as HTMLElement).style.left, + top: (element as HTMLElement).style.top, + width: (element as HTMLElement).style.width, + height: (element as HTMLElement).style.height, + })), + ) + expect(regionStyles).toEqual([ + { left: "62%", top: "52%", width: "24%", height: "6%" }, + { left: "15%", top: "68%", width: "32%", height: "5%" }, + ]) + expect(regionStyles).not.toContainEqual({ + left: "0%", + top: "0%", + width: "100%", + height: "100%", + }) + const expectedSecondRegions = [ + { x: 0.62, y: 0.52, w: 0.24, h: 0.06 }, + { x: 0.15, y: 0.68, w: 0.32, h: 0.05 }, + ] + for (const [index, expectedRegion] of expectedSecondRegions.entries()) { + const regionBox = await secondRegions.nth(index).boundingBox() + expect(regionBox).not.toBeNull() + expect(regionBox!.x).toBeCloseTo( + imageBox!.x + imageBox!.width * expectedRegion.x, + 1, + ) + expect(regionBox!.y).toBeCloseTo( + imageBox!.y + imageBox!.height * expectedRegion.y, + 1, + ) + expect(regionBox!.width).toBeCloseTo( + imageBox!.width * expectedRegion.w, + 1, + ) + expect(regionBox!.height).toBeCloseTo( + imageBox!.height * expectedRegion.h, + 1, + ) + } +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index ac9d10f..6ed5798 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ const eslintConfig = defineConfig([ "build/**", "playwright-report/**", "test-results/**", + ".repos/effect/**", "next-env.d.ts", ]), ]); diff --git a/next.config.ts b/next.config.ts index e294c70..b73f520 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,9 @@ const nextConfig: NextConfig = { "pg", "@neondatabase/serverless", "postgres", + "@ontos-ai/knowhere-sdk", + "@napi-rs/canvas", + "piscina", ], allowedDevOrigins: [ "127.0.0.1", diff --git a/package-lock.json b/package-lock.json index b8d1d91..81a0dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,9 @@ "@ai-sdk/react": "^3.0.177", "@antv/chart-visualization-skills": "0.1.3", "@effect/platform": "^0.96.1", + "@napi-rs/canvas": "^1.0.2", "@neondatabase/serverless": "^1.1.0", - "@ontos-ai/knowhere-sdk": "^2.0.0", + "@ontos-ai/knowhere-sdk": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -39,6 +40,7 @@ "next": "16.2.4", "next-themes": "^0.4.6", "pdfjs-dist": "5.4.296", + "piscina": "^5.2.0", "postgres": "^3.4.9", "posthog-js": "^1.386.8", "react": "19.2.4", @@ -72,6 +74,9 @@ "tailwindcss": "^4", "typescript": "^6.0.3", "vitest": "^4.1.5" + }, + "engines": { + "node": ">=22.13.0" } }, "node_modules/@ai-sdk/gateway": { @@ -1903,9 +1908,10 @@ "license": "MIT" }, "node_modules/@napi-rs/canvas": { - "version": "0.1.100", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.7.tgz", + "integrity": "sha512-26wVFEgs6gbe7wmzCrud1pK8q6oOgcUu7OOF24BuazB8ZUCskU9ZSLrCjoWIFVxx09rjAxsXxPleaWowHvdPCA==", "license": "MIT", - "optional": true, "workspaces": [ "e2e/*" ], @@ -1917,28 +1923,30 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.100", - "@napi-rs/canvas-darwin-arm64": "0.1.100", - "@napi-rs/canvas-darwin-x64": "0.1.100", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", - "@napi-rs/canvas-linux-arm64-musl": "0.1.100", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-musl": "0.1.100", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", - "@napi-rs/canvas-win32-x64-msvc": "0.1.100" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.100", + "@napi-rs/canvas-android-arm64": "1.0.7", + "@napi-rs/canvas-darwin-arm64": "1.0.7", + "@napi-rs/canvas-darwin-x64": "1.0.7", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.7", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.7", + "@napi-rs/canvas-linux-arm64-musl": "1.0.7", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.7", + "@napi-rs/canvas-linux-x64-gnu": "1.0.7", + "@napi-rs/canvas-linux-x64-musl": "1.0.7", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.7", + "@napi-rs/canvas-win32-x64-msvc": "1.0.7" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.7.tgz", + "integrity": "sha512-d5p4hHTykc/9PjiBXkvCH/IC5hT5jH7BjQ1u8ITq+G8x4VmvveOHdy/4BWYcC3ebWRmrG9zEc/oCTy+Yf3iZ4A==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { "node": ">= 10" @@ -1948,17 +1956,17 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", - "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.7.tgz", + "integrity": "sha512-jKfZl2QDqBr6/Ap/8NKkX0Po9SFfVjUPBJbQQmYGVtQQIazWWIAO60riH3Mz2sOyysa2oJO39sLEfXerypu0vg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], "engines": { "node": ">= 10" @@ -1968,10 +1976,10 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", - "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.7.tgz", + "integrity": "sha512-v1asrnKBu0tD+sdSD2qVIUfhoXSrr8LFTn7z76pN+8xsiOrmFcAVty57R/5DB8ZNqNLKsUBDXFSrzf4Wt9NaSg==", "cpu": [ "x64" ], @@ -1988,10 +1996,10 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", - "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.7.tgz", + "integrity": "sha512-c4Hcu4L5bXCgLY8jrZ2hy/Avl65MsbfH9DqNWrrd9InbpBZZF/rmrh7XkgmTUmOFcUrt/PaFSBinW2C0moDgcA==", "cpu": [ "arm" ], @@ -2008,13 +2016,16 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", - "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.7.tgz", + "integrity": "sha512-OVW6T1x65BTVfWb//xylCoWxbdII+nzHu3L5T035aerBAnZ5e0nmeTMkMEO9qQrVJq4WcWW8hp+T0dF+JvJPUQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2028,13 +2039,16 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", - "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.7.tgz", + "integrity": "sha512-KX/k1UO61XdKlXxhtIkq1ZUH8uP+NNK4vSrBcM2/4wWUUCZaG93BU5s0KuE9n+TFn0jEoqLSlXOuH1pYuV5dcQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2048,12 +2062,367 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", - "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.7.tgz", + "integrity": "sha512-r+0Bg+fK8r2AsrbeT7JwBUAERZSjlyhfAMQfZE/zxYGmbswS92hN0FPjynVWbeuz5fIrLfZ6JA5pH8V6drN6qw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.7.tgz", + "integrity": "sha512-0tT9KzEcTfb6MWdUWIDfy6uq6UNF691r/9NfXxxl8s9+G5QrD7VP1UC+PAwzsCzJTClIKyidNau/grYTL+QmHw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.7.tgz", + "integrity": "sha512-rvT0xo1xA+C7e+U/2ngsxWsl9gC5knHU+z8KTT5VK0Zos4AQbWZvtjvegrmzxm4o2yHE32Lexj6CDEJNvuTczA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.7.tgz", + "integrity": "sha512-N6s3yoFFPUDkfRpjUiKERVYkli7ex5Sf0Bu/My6D6gOGYxYolC1KvL6x8U0aN+ScCWAzbkIWcMYR9g0OGsFuVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.7.tgz", + "integrity": "sha512-SKTNdBV/ljfhkPsLhXjm4x2PQ0ILpc0PhkBzRHuroJXidZUaF5yh0j3s3dIzB8PrRmW+nEASzyMTaWT3nH1ywQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", "cpu": [ - "riscv64" + "x64" + ], + "libc": [ + "musl" ], "license": "MIT", "optional": true, @@ -2062,58 +2431,46 @@ ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", - "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", - "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", - "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", "cpu": [ - "arm64" + "ia32" ], "license": "MIT", "optional": true, @@ -2122,16 +2479,12 @@ ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@napi-rs/canvas/node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", - "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", "cpu": [ "x64" ], @@ -2142,10 +2495,6 @@ ], "engines": { "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -2275,16 +2624,16 @@ } }, "node_modules/@ontos-ai/knowhere-sdk": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@ontos-ai/knowhere-sdk/-/knowhere-sdk-2.0.0.tgz", - "integrity": "sha512-Zhx8mn/8pYyhBaO9BOdQYsVZybGXyq15YctjHSU8TwgNjJEzX5lEiBEkp0kIGFPh3S+/7X3rZGakefEPf5G2tQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ontos-ai/knowhere-sdk/-/knowhere-sdk-2.2.0.tgz", + "integrity": "sha512-+kFykBxJ6BzcTAiyl7s7vgWO6Gtay7VH3n6/QLXRmemOjm8qk00gfE105wFFrkrkYzBXu4rFQaX5hxHM9uRWCA==", "license": "MIT", "dependencies": { "axios": "^1.15.2", "jszip": "^3.10.0" }, "engines": { - "node": ">=20.19.0", + "node": ">=22.13.0", "npm": ">=10.0.0", "pnpm": ">=9.0.0" } @@ -7817,7 +8166,6 @@ }, "node_modules/fsevents": { "version": "2.3.2", - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11389,93 +11737,358 @@ "lines-and-columns": "^1.1.6" }, "engines": { - "node": ">=8" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/parse-ms": { - "version": "4.0.0", - "dev": true, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/parse5": { - "version": "8.0.1", - "dev": true, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "entities": "^8.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" }, "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "dev": true, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/pdfjs-dist": { - "version": "5.4.296", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" + "node": ">= 10" }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.80" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/picocolors": { @@ -11493,6 +12106,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/piscina": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.3.0.tgz", + "integrity": "sha512-9D3tPBayfoal6l9l4Y8NrMn1jkV718qJoYrla6P51+hh9h0Q+9/1c8KgEnoBQqhguyfgjay4biADI8HJG3pkoA==", + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "dev": true, @@ -15453,111 +16078,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", - "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", - "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", - "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", - "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", - "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", - "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 73b1e26..f377889 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,14 @@ "name": "knowhere-notebook", "version": "0.1.0", "private": true, + "engines": { + "node": ">=22.13.0" + }, "scripts": { "dev": "pnpm run upstash:dev & next dev", "build": "next build", "typecheck": "tsc --noEmit", - "prepare": "effect-language-service patch", + "prepare": "./scripts/prepare-effect.sh && effect-language-service patch", "start": "next start", "lint": "eslint", "test": "vitest run", @@ -23,8 +26,9 @@ "@ai-sdk/react": "^3.0.177", "@antv/chart-visualization-skills": "0.1.3", "@effect/platform": "^0.96.1", + "@napi-rs/canvas": "^1.0.2", "@neondatabase/serverless": "^1.1.0", - "@ontos-ai/knowhere-sdk": "^2.0.0", + "@ontos-ai/knowhere-sdk": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -51,6 +55,7 @@ "next": "16.2.4", "next-themes": "^0.4.6", "pdfjs-dist": "5.4.296", + "piscina": "^5.2.0", "postgres": "^3.4.9", "posthog-js": "^1.386.8", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c4223f..8847510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,15 @@ importers: '@effect/platform': specifier: ^0.96.1 version: 0.96.1(effect@3.21.2) + '@napi-rs/canvas': + specifier: ^1.0.2 + version: 1.0.2 '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 '@ontos-ai/knowhere-sdk': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.2.0 + version: 2.2.0 '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -101,6 +104,9 @@ importers: pdfjs-dist: specifier: 5.4.296 version: 5.4.296 + piscina: + specifier: ^5.2.0 + version: 5.2.0 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -167,7 +173,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -194,7 +200,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -1241,24 +1247,48 @@ packages: cpu: [arm64] os: [android] + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.100': resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.100': resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} engines: {node: '>= 10'} @@ -1266,6 +1296,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} @@ -1273,6 +1310,13 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} @@ -1280,6 +1324,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} @@ -1287,6 +1338,13 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} @@ -1294,22 +1352,158 @@ packages: os: [linux] libc: [musl] + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.100': resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas@0.1.100': resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} engines: {node: '>= 10'} + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} + engines: {node: '>= 10'} + + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1409,9 +1603,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@ontos-ai/knowhere-sdk@2.0.0': - resolution: {integrity: sha512-Zhx8mn/8pYyhBaO9BOdQYsVZybGXyq15YctjHSU8TwgNjJEzX5lEiBEkp0kIGFPh3S+/7X3rZGakefEPf5G2tQ==} - engines: {node: '>=20.19.0', npm: '>=10.0.0', pnpm: '>=9.0.0'} + '@ontos-ai/knowhere-sdk@2.2.0': + resolution: {integrity: sha512-+kFykBxJ6BzcTAiyl7s7vgWO6Gtay7VH3n6/QLXRmemOjm8qk00gfE105wFFrkrkYzBXu4rFQaX5hxHM9uRWCA==} + engines: {node: '>=22.13.0', npm: '>=10.0.0', pnpm: '>=9.0.0'} '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2415,6 +2609,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2533,8 +2731,8 @@ packages: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} - axios@1.16.0: - resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -3370,8 +3568,8 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-polyfill@4.0.10: @@ -3522,6 +3720,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -3552,6 +3754,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -4501,6 +4707,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + piscina@5.2.0: + resolution: {integrity: sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==} + engines: {node: '>=20.x'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -5413,6 +5623,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6284,36 +6499,69 @@ snapshots: '@napi-rs/canvas-android-arm64@0.1.100': optional: true + '@napi-rs/canvas-android-arm64@1.0.2': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.100': optional: true + '@napi-rs/canvas-darwin-arm64@1.0.2': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.100': optional: true + '@napi-rs/canvas-darwin-x64@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.100': optional: true + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.100': optional: true + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.100': optional: true + '@napi-rs/canvas-linux-x64-musl@1.0.2': + optional: true + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': optional: true + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.100': optional: true + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + optional: true + '@napi-rs/canvas@0.1.100': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.100 @@ -6329,6 +6577,92 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.100 optional: true + '@napi-rs/canvas@1.0.2': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 + + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -6397,12 +6731,13 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@ontos-ai/knowhere-sdk@2.0.0': + '@ontos-ai/knowhere-sdk@2.2.0': dependencies: - axios: 1.16.0 + axios: 1.18.1 jszip: 3.10.1 transitivePeerDependencies: - debug + - supports-color '@open-draft/deferred-promise@2.2.0': {} @@ -7238,10 +7573,10 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) optionalDependencies: babel-plugin-react-compiler: 1.0.0 @@ -7254,14 +7589,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.3(@types/node@20.19.39)(typescript@6.0.3) - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.5': dependencies: @@ -7300,6 +7635,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ai@6.0.175(zod@4.4.3): @@ -7443,13 +7784,15 @@ snapshots: axe-core@4.11.4: {} - axios@1.16.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color axobject-query@4.1.0: {} @@ -8397,12 +8740,12 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -8539,6 +8882,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8592,6 +8939,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -9715,6 +10069,10 @@ snapshots: picomatch@4.0.4: {} + piscina@5.2.0: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pkce-challenge@5.0.1: {} playwright-core@1.58.2: {} @@ -10653,7 +11011,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0): + vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -10666,11 +11024,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.21.0 + yaml: 2.9.0 - vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)): + vitest@4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.19.39)(jsdom@29.1.1(@noble/hashes@1.8.0))(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@20.19.39)(typescript@6.0.3))(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -10687,7 +11046,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0) + vite: 8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -10799,6 +11158,9 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: + optional: true + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d989c76..80ee5bb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ ignoredBuiltDependencies: - sharp - unrs-resolver + minimumReleaseAgeExclude: - - '@ontos-ai/knowhere-sdk@2.0.0' + - '@ontos-ai/knowhere-sdk@2.1.1' diff --git a/scripts/prepare-effect.sh b/scripts/prepare-effect.sh new file mode 100755 index 0000000..24a9d31 --- /dev/null +++ b/scripts/prepare-effect.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +set -eu + +repo_dir=".repos/effect" +repo_url="https://github.com/Effect-TS/effect-smol" + +if [ -d "$repo_dir/.git" ]; then + exit 0 +fi + +mkdir -p ".repos" +git clone "$repo_url" "$repo_dir" diff --git a/src/agent-harness/image-asset-identity.ts b/src/agent-harness/image-asset-identity.ts new file mode 100644 index 0000000..66037b1 --- /dev/null +++ b/src/agent-harness/image-asset-identity.ts @@ -0,0 +1,46 @@ +import type { EvidenceAsset, EvidenceChunk } from "./types" + +export function getCanonicalImageAssetKey( + asset: EvidenceAsset, + chunksByRef: ReadonlyMap, +): string { + const chunk = chunksByRef.get(asset.chunkRef) + const documentId = getTrimmedValue( + asset.source.documentId ?? chunk?.source.documentId, + ) + const revisionKey = getTrimmedValue(asset.revisionKey ?? chunk?.revisionKey) + const sourcePath = getTrimmedValue(asset.sourcePath)?.toLowerCase() + + if (documentId && revisionKey && sourcePath) { + return `document:${documentId}\u0000${revisionKey}\u0000${sourcePath}` + } + + const assetUrl = getNormalizedAssetUrl(asset.assetUrl) + if (assetUrl) return `url:${assetUrl}` + + if (documentId && sourcePath) { + return `document:${documentId}\u0000${sourcePath}` + } + + return asset.ref +} + +function getNormalizedAssetUrl(value: string | undefined): string | null { + const trimmed = getTrimmedValue(value) + if (!trimmed) return null + + try { + const url = new URL(trimmed) + url.hash = "" + return url.toString() + } catch { + return trimmed + } +} + +function getTrimmedValue( + value: string | null | undefined, +): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/agent-harness/image-highlights.test.ts b/src/agent-harness/image-highlights.test.ts new file mode 100644 index 0000000..631ccc1 --- /dev/null +++ b/src/agent-harness/image-highlights.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" + +import { + mergeImageInspectionHighlights, + normalizeHighlightBoxes, + normalizeImageInspectionHighlights, +} from "./image-highlights" + +describe("image highlights", () => { + it("normalizes multi-page multi-region boxes and drops invalid refs", () => { + const highlights = normalizeImageInspectionHighlights({ + allowedRefs: new Set(["asset:page-1", "asset:page-2"]), + pages: [ + { + ref: "asset:page-1", + regions: [ + { x: 0.1, y: 0.2, w: 0.3, h: 0.1 }, + { x: 0.5, y: 0.6, w: 0.2, h: 0.15 }, + ], + }, + { + ref: "asset:page-2", + regions: [{ x: -0.1, y: 0.9, w: 0.3, h: 0.2 }], + }, + { + ref: "asset:unknown", + regions: [{ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }], + }, + ], + }) + + expect(highlights).toEqual([ + { + ref: "asset:page-1", + regions: [ + { x: 0.1, y: 0.2, w: 0.3, h: 0.1 }, + { x: 0.5, y: 0.6, w: 0.2, h: 0.15 }, + ], + }, + { + ref: "asset:page-2", + regions: [{ x: 0, y: 0.9, w: 0.2, h: 0.1 }], + }, + ]) + }) + + it("clamps standalone highlight boxes for persistence reload", () => { + expect( + normalizeHighlightBoxes([ + { x: -0.2, y: 0.5, w: 0.4, h: 0.2 }, + { x: 0.1, y: 0.1, w: 0.001, h: 0.2 }, + ]), + ).toEqual([{ x: 0, y: 0.5, w: 0.2, h: 0.2 }]) + }) + + it("replaces prior regions for the same ref on later inspects", () => { + expect( + mergeImageInspectionHighlights( + [ + { + ref: "asset:page-1", + regions: [{ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }], + }, + ], + [ + { + ref: "asset:page-1", + regions: [ + { x: 0.2, y: 0.3, w: 0.1, h: 0.1 }, + { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, + ], + }, + { + ref: "asset:page-3", + regions: [{ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }], + }, + ], + ), + ).toEqual([ + { + ref: "asset:page-1", + regions: [ + { x: 0.2, y: 0.3, w: 0.1, h: 0.1 }, + { x: 0.5, y: 0.5, w: 0.1, h: 0.1 }, + ], + }, + { + ref: "asset:page-3", + regions: [{ x: 0.1, y: 0.1, w: 0.2, h: 0.2 }], + }, + ]) + }) +}) diff --git a/src/agent-harness/image-highlights.ts b/src/agent-harness/image-highlights.ts new file mode 100644 index 0000000..8adc037 --- /dev/null +++ b/src/agent-harness/image-highlights.ts @@ -0,0 +1,104 @@ +import type { ImageHighlightBox, ImageInspectionHighlights } from "./types" + +const MIN_BOX_SIZE = 0.01 + +export function normalizeImageInspectionHighlights(input: { + readonly pages: readonly { + readonly ref: string + readonly regions: readonly { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + }[] + }[] + readonly allowedRefs: ReadonlySet +}): ImageInspectionHighlights[] { + const byRef = new Map() + + for (const page of input.pages) { + const ref = page.ref.trim() + if (!ref || !input.allowedRefs.has(ref)) continue + + const regions = normalizeHighlightBoxes(page.regions) + if (regions.length === 0) continue + + const existing = byRef.get(ref) ?? [] + byRef.set(ref, [...existing, ...regions]) + } + + return Array.from(byRef.entries()).map(([ref, regions]) => ({ + ref, + regions, + })) +} + +/** Clamp/normalize boxes for persistence and reload paths. */ +export function normalizeHighlightBoxes( + regions: readonly { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + }[], +): ImageHighlightBox[] { + return regions + .map(clampHighlightBox) + .filter((box): box is ImageHighlightBox => box !== null) +} + +export function mergeImageInspectionHighlights( + existing: readonly ImageInspectionHighlights[] | undefined, + incoming: readonly ImageInspectionHighlights[] | undefined, +): ImageInspectionHighlights[] { + const byRef = new Map() + + for (const page of existing ?? []) { + byRef.set(page.ref, [...page.regions]) + } + for (const page of incoming ?? []) { + // Latest inspect for a ref replaces prior regions for that ref. + byRef.set(page.ref, [...page.regions]) + } + + return Array.from(byRef.entries()).map(([ref, regions]) => ({ + ref, + regions, + })) +} + +function clampHighlightBox(input: { + readonly x: number + readonly y: number + readonly w: number + readonly h: number +}): ImageHighlightBox | null { + if ( + !Number.isFinite(input.x) || + !Number.isFinite(input.y) || + !Number.isFinite(input.w) || + !Number.isFinite(input.h) + ) { + return null + } + + const x1 = clamp01(Math.min(input.x, input.x + input.w)) + const y1 = clamp01(Math.min(input.y, input.y + input.h)) + const x2 = clamp01(Math.max(input.x, input.x + input.w)) + const y2 = clamp01(Math.max(input.y, input.y + input.h)) + const w = x2 - x1 + const h = y2 - y1 + if (w < MIN_BOX_SIZE || h < MIN_BOX_SIZE) return null + + return { x: round01(x1), y: round01(y1), w: round01(w), h: round01(h) } +} + +function clamp01(value: number): number { + if (value <= 0) return 0 + if (value >= 1) return 1 + return value +} + +function round01(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000 +} diff --git a/src/agent-harness/index.ts b/src/agent-harness/index.ts index bca503f..a2666bd 100644 --- a/src/agent-harness/index.ts +++ b/src/agent-harness/index.ts @@ -1,4 +1,5 @@ +export * from "./image-highlights" export * from "./ledger" +export * from "./knowhere-text" export * from "./runtime" export * from "./types" -export * from "./validator" diff --git a/src/agent-harness/knowhere-text.test.ts b/src/agent-harness/knowhere-text.test.ts new file mode 100644 index 0000000..e916cad --- /dev/null +++ b/src/agent-harness/knowhere-text.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest" +import type { + KnowledgeGrepResponse, + KnowledgeOutline, + KnowledgeReadResponse, + RetrievalQueryResponse, +} from "@ontos-ai/knowhere-sdk" + +import { createEvidenceLedger } from "./ledger" +import { knowhereToolText } from "./knowhere-text" + +describe("knowhereToolText", () => { + it("formats search results as tagged text with refs and no raw asset URLs", () => { + const ledger = createEvidenceLedger() + const response = makeSearchResponse() + const snapshot = ledger.addRetrievalResponse(response) + + const text = knowhereToolText.formatSearch({ + response, + retrievalCount: snapshot.retrievalCount, + chunks: snapshot.chunks, + assets: snapshot.assets, + }) + + expect(text).toContain('') + expect(text).toContain('ref="r1:result:1"') + expect(text).toContain('ref="asset:r1:result:1"') + expect(text).toContain("Call inspectImage") + expect(text).toContain("before finalize") + expect(text).not.toContain("https://assets.example/page-1.png") + }) + + it("formats list and outline responses", () => { + const listText = knowhereToolText.formatListDocuments({ + documents: [ + { + documentId: "doc_1", + revisionKey: "job_1", + namespace: "notebook", + sourceFileName: "report.pdf", + title: "Report", + status: "ready", + }, + ], + }) + const outlineText = knowhereToolText.formatOutline(makeOutlineResponse()) + + expect(listText).toContain('') + expect(listText).toContain('documentId="doc_1"') + expect(outlineText).toContain( + '', + ) + expect(outlineText).toContain('sectionPath="Root / Revenue"') + }) + + it("formats full read chunk bodies without truncating large content", () => { + const ledger = createEvidenceLedger() + const largeContent = `BEGIN ${"full chunk body ".repeat(700)} END` + const response = makeReadResponse(largeContent) + const snapshot = ledger.addReadChunksResponse(response) + + const text = knowhereToolText.formatReadChunks({ + response, + chunks: snapshot.chunks, + assets: snapshot.assets, + }) + + expect(text).toContain('') + expect(text).toContain('ref="read1:chunk:1"') + expect(text).toContain(largeContent) + expect(text).not.toContain("...[truncated]") + expect(text).not.toContain("https://assets.example/page-1.png") + }) + + it("formats grep matches with continuation metadata", () => { + const ledger = createEvidenceLedger() + const response = makeGrepResponse() + const snapshot = ledger.addGrepChunksResponse(response) + + const text = knowhereToolText.formatGrepChunks({ + response, + chunks: snapshot.chunks, + assets: snapshot.assets, + }) + + expect(text).toContain('') + expect(text).toContain('truncated="true"') + expect(text).toContain('continuationCursor="cursor_2"') + expect(text).toContain('ref="grep1:match:1"') + expect(text).toContain("matched penalty snippet") + }) + + it("formats errors as tagged text", () => { + expect( + knowhereToolText.formatError({ + operation: "read_chunks", + message: "A documentId is required.", + }), + ).toBe( + [ + '', + "", + "A documentId is required.", + "", + "", + ].join("\n"), + ) + }) +}) + +function makeSearchResponse(): RetrievalQueryResponse { + return { + namespace: "notebook", + query: "page one", + routerUsed: "workflow_single_step", + answerText: null, + evidenceText: "Page one evidence.", + stopReason: "answer_done", + failureReason: null, + results: [ + { + content: "Page one summary.", + chunkType: "page", + score: 0.91, + assetUrl: "https://assets.example/page-1.png", + metadata: { + pageNums: [1], + pageAssets: [ + { + pageNum: 1, + artifactRef: "page_citation_assets/page-1.png", + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }, + source: { + documentId: "doc_1", + sourceFileName: "report.pdf", + sectionPath: "Root / Page 1", + }, + }, + ], + referencedChunks: [], + } +} + +function makeReadResponse(content: string): KnowledgeReadResponse { + return { + document: { + localDocumentId: "doc_1", + documentId: "doc_1", + jobId: "job_1", + namespace: "notebook", + sourceFileName: "report.pdf", + chunkCount: 1, + typeCounts: { text: 0, image: 0, table: 0, page: 1 }, + resultDirectoryPath: "parsed-storage:doc_1/job_1", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + chunks: [ + { + position: 1, + chunkId: "chunk_page_1", + chunkType: "page", + contentSource: "content", + content, + readableContent: content, + sectionPath: "Root / Page 1", + sourceChunkPath: "pages/page-1.md", + filePath: "pages/page-1.png", + assetUrl: "https://assets.example/page-1.png", + pageNumbers: [1], + metadata: { + pageNums: [1], + pageAssets: [ + { + pageNum: 1, + artifactRef: "page_citation_assets/page-1.png", + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }, + }, + ], + page: 1, + pageSize: 1, + totalChunks: 1, + totalPages: 1, + } +} + +function makeOutlineResponse(): KnowledgeOutline { + return { + document: { + localDocumentId: "doc_1", + documentId: "doc_1", + jobId: "job_1", + namespace: "notebook", + sourceFileName: "report.pdf", + chunkCount: 1, + typeCounts: { text: 1, image: 0, table: 0, page: 0 }, + resultDirectoryPath: "parsed-storage:doc_1/job_1", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + totalChunks: 1, + typeCounts: { text: 1, image: 0, table: 0, page: 0 }, + sections: [ + { + sectionPath: "Root / Revenue", + sectionTitle: "Revenue", + sectionLevel: 2, + summary: "Revenue summary.", + startChunk: 1, + endChunk: 1, + chunkCount: 1, + typeCounts: { text: 1, image: 0, table: 0, page: 0 }, + children: [], + }, + ], + sectionTree: [], + } +} + +function makeGrepResponse(): KnowledgeGrepResponse { + return { + document: { + localDocumentId: "doc_1", + documentId: "doc_1", + jobId: "job_1", + namespace: "notebook", + sourceFileName: "contract.pdf", + chunkCount: 4, + typeCounts: { text: 4, image: 0, table: 0, page: 0 }, + resultDirectoryPath: "parsed-storage:doc_1/job_1", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + matches: [ + { + position: 3, + chunkId: "chunk_3", + chunkType: "text", + sectionPath: "Root / Penalties", + sourceChunkPath: "chunks/chunk-3.md", + filePath: "contract.pdf", + startOffset: 10, + endOffset: 17, + snippet: "matched penalty snippet", + }, + ], + scannedChunks: 4, + truncated: true, + continuationCursor: "cursor_2", + } +} diff --git a/src/agent-harness/knowhere-text.ts b/src/agent-harness/knowhere-text.ts new file mode 100644 index 0000000..f5b19da --- /dev/null +++ b/src/agent-harness/knowhere-text.ts @@ -0,0 +1,301 @@ +import type { + KnowledgeGrepResponse, + KnowledgeOutline, + KnowledgeReadResponse, + RetrievalQueryResponse, +} from "@ontos-ai/knowhere-sdk" + +import type { + EvidenceAsset, + EvidenceChunk, + KnowhereListDocumentsResponse, +} from "./types" + +type EvidenceDelta = { + readonly chunks: readonly EvidenceChunk[] + readonly assets: readonly EvidenceAsset[] +} + +type SearchTextInput = EvidenceDelta & { + readonly response: RetrievalQueryResponse + readonly retrievalCount: number +} + +type ReadChunksTextInput = EvidenceDelta & { + readonly response: KnowledgeReadResponse +} + +type GrepChunksTextInput = EvidenceDelta & { + readonly response: KnowledgeGrepResponse +} + +type ErrorTextInput = { + readonly operation: KnowhereOperation + readonly message: string +} + +type KnowhereOperation = + | "search" + | "list_documents" + | "get_document_outline" + | "read_chunks" + | "grep_chunks" + +const assetInstruction = + "Notebook returned image/page asset refs. Call inspectImage with the asset refs you will cite before finalize so OCR/visual context and provenance boxes exist. Do not expose raw asset URLs." + +export const knowhereToolText = { + formatSearch(input: SearchTextInput): string { + return wrapKnowhereBlock("search", [ + formatTag("summary", { + retrievalCount: String(input.retrievalCount), + namespace: input.response.namespace, + query: input.response.query, + resultCount: String(input.response.results.length), + referencedChunkCount: String(input.response.referencedChunks.length), + stopReason: input.response.stopReason ?? undefined, + failureReason: input.response.failureReason ?? undefined, + }), + formatOptionalTextTag("evidence", input.response.evidenceText), + formatEvidenceChunks(input.chunks), + formatEvidenceAssets(input.assets), + formatAssetInstruction(input.assets), + ]) + }, + + formatListDocuments(response: KnowhereListDocumentsResponse): string { + return wrapKnowhereBlock("list_documents", [ + formatTag("summary", { documentCount: String(response.documents.length) }), + ...response.documents.map((document, index) => + formatSelfClosingTag("document", { + index: String(index + 1), + documentId: document.documentId, + localDocumentId: document.localDocumentId, + revisionKey: document.revisionKey, + namespace: document.namespace, + sourceFileName: document.sourceFileName, + title: document.title, + status: document.status, + chunkCount: + typeof document.chunkCount === "number" + ? String(document.chunkCount) + : undefined, + }), + ), + ]) + }, + + formatOutline(response: KnowledgeOutline): string { + return wrapKnowhereBlock("get_document_outline", [ + formatTag("document", { + documentId: response.document.documentId, + localDocumentId: response.document.localDocumentId, + revisionKey: response.document.jobId, + sourceFileName: response.document.sourceFileName, + totalChunks: String(response.totalChunks), + truncated: response.truncated === true ? "true" : undefined, + continuationCursor: response.continuationCursor, + }), + ...response.sections.map((section) => formatSection(section, 0)), + ]) + }, + + formatReadChunks(input: ReadChunksTextInput): string { + return wrapKnowhereBlock("read_chunks", [ + formatTag("document", { + documentId: input.response.document.documentId, + localDocumentId: input.response.document.localDocumentId, + revisionKey: input.response.document.jobId, + sourceFileName: input.response.document.sourceFileName, + page: + typeof input.response.page === "number" + ? String(input.response.page) + : undefined, + pageSize: + typeof input.response.pageSize === "number" + ? String(input.response.pageSize) + : undefined, + totalChunks: + typeof input.response.totalChunks === "number" + ? String(input.response.totalChunks) + : undefined, + totalPages: + typeof input.response.totalPages === "number" + ? String(input.response.totalPages) + : undefined, + nextChunk: + typeof input.response.nextChunk === "number" + ? String(input.response.nextChunk) + : undefined, + }), + formatEvidenceChunks(input.chunks), + formatEvidenceAssets(input.assets), + formatAssetInstruction(input.assets), + ]) + }, + + formatGrepChunks(input: GrepChunksTextInput): string { + return wrapKnowhereBlock("grep_chunks", [ + formatTag("document", { + documentId: input.response.document.documentId, + localDocumentId: input.response.document.localDocumentId, + revisionKey: input.response.document.jobId, + sourceFileName: input.response.document.sourceFileName, + matchCount: String(input.response.matches.length), + scannedChunks: String(input.response.scannedChunks), + truncated: input.response.truncated ? "true" : "false", + continuationCursor: input.response.continuationCursor, + }), + formatEvidenceChunks(input.chunks), + ]) + }, + + formatError(input: ErrorTextInput): string { + return [ + formatOpenTag("knowhere", { + operation: input.operation, + status: "error", + }), + formatTextTag("message", input.message), + "", + ].join("\n") + }, +} as const + +function wrapKnowhereBlock( + operation: KnowhereOperation, + parts: readonly string[], +): string { + return [ + formatOpenTag("knowhere", { operation, status: "ok" }), + ...parts.filter((part) => part.trim().length > 0), + "", + ].join("\n") +} + +function formatEvidenceChunks(chunks: readonly EvidenceChunk[]): string { + if (chunks.length === 0) return "" + + return [ + "", + ...chunks.map((chunk) => + [ + formatOpenTag("chunk", { + ref: chunk.ref, + kind: chunk.kind, + chunkId: chunk.chunkId, + chunkType: chunk.chunkType, + score: chunk.score === null ? undefined : String(chunk.score), + documentId: chunk.source.documentId ?? undefined, + sourceFileName: chunk.source.sourceFileName ?? undefined, + sectionPath: chunk.source.sectionPath ?? undefined, + sourceChunkPath: chunk.sourceChunkPath ?? undefined, + filePath: chunk.filePath ?? undefined, + assetRef: chunk.assetRef, + }), + formatTextTag("content", chunk.content), + "", + ].join("\n"), + ), + "", + ].join("\n") +} + +function formatEvidenceAssets(assets: readonly EvidenceAsset[]): string { + if (assets.length === 0) return "" + + return [ + "", + ...assets.map((asset) => + formatSelfClosingTag("asset", { + ref: asset.ref, + chunkRef: asset.chunkRef, + type: asset.type, + label: asset.label, + sourcePath: asset.sourcePath, + documentId: asset.source.documentId ?? undefined, + sourceFileName: asset.source.sourceFileName ?? undefined, + sectionPath: asset.source.sectionPath ?? undefined, + }), + ), + "", + ].join("\n") +} + +function formatAssetInstruction(assets: readonly EvidenceAsset[]): string { + if (!assets.some((asset) => asset.type === "image")) return "" + return formatTextTag("asset_instruction", assetInstruction) +} + +function formatSection( + section: KnowledgeOutline["sections"][number], + depth: number, +): string { + return [ + formatOpenTag("section", { + depth: String(depth), + sectionPath: section.sectionPath, + sectionTitle: section.sectionTitle, + sectionLevel: String(section.sectionLevel), + startChunk: + typeof section.startChunk === "number" + ? String(section.startChunk) + : undefined, + endChunk: + typeof section.endChunk === "number" + ? String(section.endChunk) + : undefined, + chunkCount: String(section.chunkCount), + }), + formatOptionalTextTag("summary", section.summary), + ...section.children.map((child) => formatSection(child, depth + 1)), + "", + ] + .filter((part) => part.trim().length > 0) + .join("\n") +} + +function formatOptionalTextTag( + tagName: string, + value: string | null | undefined, +): string { + const trimmedValue = value?.trim() + return trimmedValue ? formatTextTag(tagName, trimmedValue) : "" +} + +function formatTextTag(tagName: string, value: string): string { + return [`<${tagName}>`, value, ``].join("\n") +} + +function formatTag( + tagName: string, + attrs: Readonly>, +): string { + return `${formatOpenTag(tagName, attrs)}` +} + +function formatSelfClosingTag( + tagName: string, + attrs: Readonly>, +): string { + return `${formatOpenTag(tagName, attrs).slice(0, -1)} />` +} + +function formatOpenTag( + tagName: string, + attrs: Readonly>, +): string { + const serializedAttrs = Object.entries(attrs) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(([key, value]) => `${key}="${escapeAttribute(value)}"`) + .join(" ") + return serializedAttrs ? `<${tagName} ${serializedAttrs}>` : `<${tagName}>` +} + +function escapeAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">") +} diff --git a/src/agent-harness/ledger.test.ts b/src/agent-harness/ledger.test.ts index 5685bc3..3af1275 100644 --- a/src/agent-harness/ledger.test.ts +++ b/src/agent-harness/ledger.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest" -import type { RetrievalQueryResponse } from "@ontos-ai/knowhere-sdk" +import type { + KnowledgeGrepResponse, + KnowledgeReadResponse, + RetrievalQueryResponse, +} from "@ontos-ai/knowhere-sdk" import { createEvidenceLedger } from "./ledger" @@ -43,6 +47,189 @@ describe("createEvidenceLedger", () => { contentSlice: "", }) }) + + it("creates page image assets from live page citation asset URLs without metadata", () => { + const ledger = createEvidenceLedger() + + const snapshot = ledger.addRetrievalResponse( + makePageAssetUrlRetrievalResponse(), + ) + + expect(snapshot.assets).toContainEqual( + expect.objectContaining({ + ref: "asset:r1:referenced:1", + chunkRef: "r1:referenced:1", + type: "image", + assetUrl: + "https://knowhere-storage.example/results/job_1/page_citation_assets/page-8.png?AWSAccessKeyId=test", + sourcePath: "page_citation_assets/page-8.png", + revisionKey: "job_1", + }), + ) + }) + + it("creates page image assets from snake-case retrieval metadata", () => { + const ledger = createEvidenceLedger() + + const snapshot = ledger.addRetrievalResponse({ + namespace: "notebook", + query: "revenue", + routerUsed: "mapnav", + answerText: null, + evidenceText: "Revenue evidence", + stopReason: "completed", + failureReason: null, + results: [ + { + chunkId: "chunk_page_4", + content: "Revenue was $24.9B.", + chunkType: "page", + score: 0.9, + metadata: { + page_nums: [4], + pageAssets: [], + page_assets: [ + { + page_num: 4, + artifactRef: "", + artifact_ref: "page_citation_assets/page-4.png", + contentType: "", + content_type: "image/png", + }, + ], + }, + source: { + documentId: "doc_tsla", + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "FINANCIAL SUMMARY", + }, + }, + ], + referencedChunks: [], + }) + + expect(snapshot.assets).toContainEqual( + expect.objectContaining({ + ref: "asset:r1:result:1", + sourcePath: "page_citation_assets/page-4.png", + type: "image", + }), + ) + }) + + it("adds read chunk refs and page image assets", () => { + const ledger = createEvidenceLedger() + + const snapshot = ledger.addReadChunksResponse(makeReadResponse()) + + expect(snapshot.chunks).toEqual([ + expect.objectContaining({ + ref: "read1:chunk:1", + kind: "read_chunk", + content: "Full page content.", + contentPreview: "Full page content.", + assetRef: "asset:read1:chunk:1", + }), + ]) + expect(snapshot.assets).toEqual([ + expect.objectContaining({ + ref: "asset:read1:chunk:1", + chunkRef: "read1:chunk:1", + type: "image", + sourcePath: "page_citation_assets/page-1.png", + }), + ]) + }) + + it("adds grep match refs", () => { + const ledger = createEvidenceLedger() + + const snapshot = ledger.addGrepChunksResponse(makeGrepResponse()) + + expect(snapshot.chunks).toEqual([ + expect.objectContaining({ + ref: "grep1:match:1", + kind: "grep_match", + chunkId: "chunk_3", + content: "matched penalty snippet", + source: expect.objectContaining({ + documentId: "doc_contract", + sourceFileName: "contract.pdf", + sectionPath: "Root / Penalties", + }), + }), + ]) + }) + + it("copies page metadata onto grep matches from the same chunk id", () => { + const ledger = createEvidenceLedger() + ledger.addReadChunksResponse(makeReadResponse()) + + const snapshot = ledger.addGrepChunksResponse({ + ...makeGrepResponse(), + matches: [ + { + position: 1, + chunkId: "chunk_page_1", + chunkType: "page", + sectionPath: "Root / Page 1", + sourceChunkPath: "pages/page-1.md", + startOffset: 0, + endOffset: 12, + snippet: "Full page snip", + }, + ], + }) + + expect(snapshot.chunks[1]).toEqual( + expect.objectContaining({ + ref: "grep1:match:1", + kind: "grep_match", + chunkId: "chunk_page_1", + metadata: expect.objectContaining({ + pageNums: [1], + position: 1, + startOffset: 0, + endOffset: 12, + }), + }), + ) + }) + + it("copies pageNumbers from the grep match when the SDK provides them", () => { + const ledger = createEvidenceLedger() + + const snapshot = ledger.addGrepChunksResponse({ + ...makeGrepResponse(), + matches: [ + { + position: 1, + chunkId: "chunk_page_4", + chunkType: "page", + sectionPath: "FINANCIAL SUMMARY", + sourceChunkPath: "pages/page-4.md", + startOffset: 0, + endOffset: 12, + snippet: "automotive revenues", + pageNumbers: [4], + }, + ], + }) + + expect(snapshot.chunks[0]).toEqual( + expect.objectContaining({ + ref: "grep1:match:1", + kind: "grep_match", + chunkId: "chunk_page_4", + metadata: expect.objectContaining({ + pageNums: [4], + position: 1, + startOffset: 0, + endOffset: 12, + }), + }), + ) + }) }) function makeRetrievalResponse(): RetrievalQueryResponse { @@ -89,3 +276,103 @@ function makeRetrievalResponse(): RetrievalQueryResponse { ], } } + +function makePageAssetUrlRetrievalResponse(): RetrievalQueryResponse { + return { + namespace: "notebook", + query: "承包人 进度计划 修改 违约金", + routerUsed: "workflow_single_step", + answerText: null, + evidenceText: + "Root / (6)现场工期进度管理方面的违约责任 [Page PDF (page 8)]", + stopReason: "answer_done", + failureReason: null, + results: [], + referencedChunks: [ + { + chunkId: "node_3a513cf7-77d7-5c62-a9bd-6a1109123e2c", + documentId: "doc_contract", + chunkType: "page", + sectionPath: "Root / (6)现场工期进度管理方面的违约责任", + filePath: null, + jobId: "job_1", + assetUrl: + "https://knowhere-storage.example/results/job_1/page_citation_assets/page-8.png?AWSAccessKeyId=test", + }, + ], + } +} + +function makeReadResponse(): KnowledgeReadResponse { + return { + document: { + localDocumentId: "doc_contract", + documentId: "doc_contract", + jobId: "job_contract", + namespace: "notebook", + sourceFileName: "contract.pdf", + chunkCount: 1, + typeCounts: { text: 0, image: 0, table: 0, page: 1 }, + resultDirectoryPath: "parsed-storage:doc_contract/job_contract", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + chunks: [ + { + position: 1, + chunkId: "chunk_page_1", + chunkType: "page", + content: "Full page content.", + readableContent: "Full page content.", + sectionPath: "Root / Page 1", + sourceChunkPath: "pages/page-1.md", + filePath: "pages/page-1.png", + assetUrl: "https://assets.example/page-1.png", + pageNumbers: [1], + metadata: { + pageNums: [1], + pageAssets: [ + { + pageNum: 1, + artifactRef: "page_citation_assets/page-1.png", + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }, + }, + ], + } +} + +function makeGrepResponse(): KnowledgeGrepResponse { + return { + document: { + localDocumentId: "doc_contract", + documentId: "doc_contract", + jobId: "job_contract", + namespace: "notebook", + sourceFileName: "contract.pdf", + chunkCount: 4, + typeCounts: { text: 4, image: 0, table: 0, page: 0 }, + resultDirectoryPath: "parsed-storage:doc_contract/job_contract", + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + matches: [ + { + position: 3, + chunkId: "chunk_3", + chunkType: "text", + sectionPath: "Root / Penalties", + sourceChunkPath: "chunks/chunk-3.md", + filePath: "contract.pdf", + startOffset: 10, + endOffset: 17, + snippet: "matched penalty snippet", + }, + ], + scannedChunks: 4, + truncated: false, + } +} diff --git a/src/agent-harness/ledger.ts b/src/agent-harness/ledger.ts index da197e8..42f3120 100644 --- a/src/agent-harness/ledger.ts +++ b/src/agent-harness/ledger.ts @@ -1,4 +1,8 @@ import type { + KnowledgeGrepMatch, + KnowledgeGrepResponse, + KnowledgeReadChunk, + KnowledgeReadResponse, RetrievalQueryResponse, RetrievalResult, } from "@ontos-ai/knowhere-sdk" @@ -14,6 +18,8 @@ const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"] as co type MutableLedger = { retrievalCount: number + readCount: number + grepCount: number chunks: EvidenceChunk[] assets: EvidenceAsset[] evidenceText: string[] @@ -22,11 +28,27 @@ type MutableLedger = { decisionTraces: unknown[] } +type EvidenceAssetCandidate = { + readonly type: EvidenceAsset["type"] + readonly assetUrl?: string + readonly sourcePath?: string + readonly label: string +} + +type PageCitationAssetCandidate = { + readonly pageNum: number + readonly artifactRef?: string + readonly assetUrl?: string + readonly contentType?: string +} + export type EvidenceLedger = ReturnType export function createEvidenceLedger() { const ledger: MutableLedger = { retrievalCount: 0, + readCount: 0, + grepCount: 0, chunks: [], assets: [], evidenceText: [], @@ -68,15 +90,20 @@ export function createEvidenceLedger() { chunk: { ref: `r${retrievalIndex}:referenced:${index + 1}`, kind: "referenced_chunk", + chunkId: chunk.chunkId, content, contentPreview: content, chunkType: chunk.chunkType, score: null, + sourceChunkPath: chunk.sourceChunkPath, + filePath: chunk.filePath, + metadata: chunk.metadata, source: { documentId: chunk.documentId, sourceFileName: null, sectionPath: chunk.sectionPath, }, + ...(chunk.jobId ? { revisionKey: chunk.jobId } : {}), ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}), }, }) @@ -85,6 +112,38 @@ export function createEvidenceLedger() { return snapshot(ledger) }, + addReadChunksResponse(response: KnowledgeReadResponse): EvidenceLedgerSnapshot { + ledger.readCount += 1 + const readIndex = ledger.readCount + + response.chunks.forEach((chunk, index) => { + addChunkFromReadChunk({ + ledger, + response, + chunk, + ref: `read${readIndex}:chunk:${index + 1}`, + }) + }) + + return snapshot(ledger) + }, + + addGrepChunksResponse(response: KnowledgeGrepResponse): EvidenceLedgerSnapshot { + ledger.grepCount += 1 + const grepIndex = ledger.grepCount + + response.matches.forEach((match, index) => { + addChunkFromGrepMatch({ + ledger, + response, + match, + ref: `grep${grepIndex}:match:${index + 1}`, + }) + }) + + return snapshot(ledger) + }, + read(ref: string, offset = 0, limit = 4_000) { const chunk = ledger.chunks.find((candidate) => candidate.ref === ref) if (!chunk) { @@ -146,6 +205,10 @@ function addChunkFromResult(input: { contentPreview: buildContentPreview(input.result.content), chunkType: input.result.chunkType, score: input.result.score, + chunkId: input.result.chunkId, + sourceChunkPath: input.result.sourceChunkPath, + filePath: input.result.filePath, + metadata: input.result.metadata, source: { documentId: input.result.source.documentId, sourceFileName: input.result.source.sourceFileName, @@ -156,17 +219,110 @@ function addChunkFromResult(input: { }) } +function addChunkFromReadChunk(input: { + readonly ledger: MutableLedger + readonly response: KnowledgeReadResponse + readonly chunk: KnowledgeReadChunk + readonly ref: string +}): void { + addChunk({ + ledger: input.ledger, + chunk: { + ref: input.ref, + kind: "read_chunk", + chunkId: input.chunk.chunkId, + content: input.chunk.content, + contentPreview: buildContentPreview(input.chunk.content), + chunkType: input.chunk.chunkType, + score: null, + sourceChunkPath: input.chunk.sourceChunkPath, + filePath: input.chunk.filePath, + metadata: input.chunk.metadata, + source: { + documentId: input.response.document.documentId, + sourceFileName: input.response.document.sourceFileName, + sectionPath: input.chunk.sectionPath, + }, + revisionKey: input.response.document.jobId, + ...(input.chunk.assetUrl ? { assetUrl: input.chunk.assetUrl } : {}), + }, + }) +} + +function addChunkFromGrepMatch(input: { + readonly ledger: MutableLedger + readonly response: KnowledgeGrepResponse + readonly match: KnowledgeGrepMatch + readonly ref: string +}): void { + const donor = input.ledger.chunks.find( + (chunk) => + chunk.chunkId === input.match.chunkId && hasPageMetadata(chunk.metadata), + ) + const pageNums = + input.match.pageNumbers && input.match.pageNumbers.length > 0 + ? [...input.match.pageNumbers] + : undefined + + addChunk({ + ledger: input.ledger, + chunk: { + ref: input.ref, + kind: "grep_match", + chunkId: input.match.chunkId, + content: input.match.snippet, + contentPreview: buildContentPreview(input.match.snippet), + chunkType: input.match.chunkType, + score: null, + sourceChunkPath: input.match.sourceChunkPath, + filePath: input.match.filePath, + metadata: { + ...(donor?.metadata ?? {}), + ...(pageNums ? { pageNums } : {}), + position: input.match.position, + startOffset: input.match.startOffset, + endOffset: input.match.endOffset, + }, + source: { + documentId: input.response.document.documentId, + sourceFileName: input.response.document.sourceFileName, + sectionPath: input.match.sectionPath, + }, + revisionKey: input.response.document.jobId, + }, + }) +} + +function hasPageMetadata( + metadata: Readonly> | undefined, +): boolean { + if (!metadata) return false + const values = [ + metadata.pageNums, + metadata.page_nums, + metadata.pageNum, + metadata.page_num, + metadata.pageAssets, + metadata.page_assets, + ] + return values.some((value) => { + if (Array.isArray(value)) return value.length > 0 + if (typeof value === "number") return Number.isSafeInteger(value) && value > 0 + if (typeof value === "string") return value.trim().length > 0 + return false + }) +} + function addChunk(input: { readonly ledger: MutableLedger readonly chunk: Omit }): void { - const assetUrl = input.chunk.assetUrl?.trim() - if (!assetUrl || !isRenderableAsset(input.chunk.chunkType, assetUrl)) { + const asset = getEvidenceAssetCandidate(input.chunk) + if (!asset) { input.ledger.chunks.push(input.chunk) return } - const type = getAssetType(input.chunk.chunkType, assetUrl) const assetRef = `asset:${input.chunk.ref}` const chunk: EvidenceChunk = { ...input.chunk, @@ -176,10 +332,12 @@ function addChunk(input: { input.ledger.assets.push({ ref: assetRef, chunkRef: chunk.ref, - type, - assetUrl, + type: asset.type, + ...(asset.assetUrl ? { assetUrl: asset.assetUrl } : {}), + ...(asset.sourcePath ? { sourcePath: asset.sourcePath } : {}), + ...(chunk.revisionKey ? { revisionKey: chunk.revisionKey } : {}), source: chunk.source, - label: formatAssetLabel(chunk), + label: asset.label, }) } @@ -198,6 +356,58 @@ function isRenderableAsset(chunkType: string, assetUrl: string): boolean { ) } +function getEvidenceAssetCandidate( + chunk: Omit, +): EvidenceAssetCandidate | null { + const pageAsset = getPageCitationAssetCandidate(chunk) + if (pageAsset) { + return pageAsset + } + + const assetUrl = getTrimmedString(chunk.assetUrl) + if (!assetUrl || !isRenderableAsset(chunk.chunkType, assetUrl)) return null + + const sourcePath = getAssetSourcePath(chunk, assetUrl) + return { + type: getAssetType(chunk.chunkType, assetUrl), + assetUrl, + ...(sourcePath ? { sourcePath } : {}), + label: formatAssetLabel(chunk, sourcePath), + } +} + +function getPageCitationAssetCandidate( + chunk: Omit, +): EvidenceAssetCandidate | null { + if (chunk.chunkType.toLowerCase() !== "page") return null + + const candidates = [ + ...parsePageCitationAssetCandidates(chunk.metadata?.pageAssets), + ...parsePageCitationAssetCandidates(chunk.metadata?.page_assets), + ].filter(isSupportedPageCitationAsset) + if (candidates.length === 0) return null + + const pageNumbers = getPageNumbers(chunk.metadata) + const candidate = + pageNumbers.length > 0 + ? candidates.find((item) => pageNumbers.includes(item.pageNum)) ?? + candidates[0] + : candidates[0] + if (!candidate) return null + + const sourcePath = getTrimmedString(candidate.artifactRef) + const assetUrl = + getTrimmedString(candidate.assetUrl) ?? getTrimmedString(chunk.assetUrl) + if (!sourcePath && !assetUrl) return null + + return { + type: "image", + ...(assetUrl ? { assetUrl } : {}), + ...(sourcePath ? { sourcePath } : {}), + label: formatAssetLabel(chunk, sourcePath), + } +} + function getAssetType(chunkType: string, assetUrl: string): "image" | "table" { return chunkType.toLowerCase() === "table" && !isImageAssetUrl(assetUrl) ? "table" @@ -209,6 +419,37 @@ function isImageAssetUrl(assetUrl: string): boolean { return imageExtensions.some((extension) => pathname.endsWith(extension)) } +function getAssetSourcePath( + chunk: Omit, + assetUrl: string, +): string | null { + const candidates = [ + chunk.filePath, + chunk.sourceChunkPath, + chunk.source.sectionPath, + getUrlPathname(assetUrl), + ] + + for (const candidate of candidates) { + const sourcePath = getSupportedAssetPath(candidate) + if (sourcePath) return sourcePath + } + + return null +} + +function getSupportedAssetPath(value: string | null | undefined): string | null { + const normalizedText = normalizeSourcePathCandidate(value) + if (!normalizedText) return null + + const match = + /(?:^|\/)((?:images|tables|pages|page_citation_assets)\/[^?#]+)(?:[?#]|$)?/i.exec( + normalizedText, + ) + const matchedPath = match?.[1] + return matchedPath ? matchedPath.trim() : null +} + function getUrlPathname(assetUrl: string): string { try { return new URL(assetUrl).pathname @@ -217,14 +458,146 @@ function getUrlPathname(assetUrl: string): string { } } -function formatAssetLabel(chunk: EvidenceChunk): string { - return [ +function parsePageCitationAssetCandidates( + value: unknown, +): readonly PageCitationAssetCandidate[] { + if (!Array.isArray(value)) return [] + + return value.flatMap((item): PageCitationAssetCandidate[] => { + if (!isRecord(item)) return [] + const pageNum = + getPositiveInteger(item.pageNum) ?? + getPositiveInteger(item.page_num) ?? + getPositiveInteger(item.pageNumber) + if (!pageNum) return [] + const artifactRef = + getTrimmedString(item.artifactRef) ?? + getTrimmedString(item.artifact_ref) + const assetUrl = + getTrimmedString(item.assetUrl) ?? getTrimmedString(item.asset_url) + const contentType = + getTrimmedString(item.contentType) ?? + getTrimmedString(item.content_type) + + return [ + { + pageNum, + ...(artifactRef ? { artifactRef } : {}), + ...(assetUrl ? { assetUrl } : {}), + ...(contentType ? { contentType } : {}), + }, + ] + }) +} + +function isSupportedPageCitationAsset( + candidate: PageCitationAssetCandidate, +): boolean { + const contentType = candidate.contentType?.toLowerCase() + return ( + contentType?.startsWith("image/") === true || + hasImageExtension(candidate.artifactRef) || + hasImageExtension(candidate.assetUrl) + ) +} + +function hasImageExtension(value: string | null | undefined): boolean { + const normalized = normalizeSourcePathCandidate(value)?.toLowerCase() + return ( + normalized !== undefined && + imageExtensions.some((extension) => normalized.endsWith(extension)) + ) +} + +function getPageNumbers( + metadata: Readonly> | undefined, +): readonly number[] { + if (!metadata) return [] + + const values = [ + metadata.pageNums, + metadata.page_nums, + metadata.pageNum, + metadata.page_num, + ] + const pageNumbers = new Set() + + for (const value of values) { + if (Array.isArray(value)) { + for (const item of value) { + const pageNum = getPositiveInteger(item) + if (pageNum) pageNumbers.add(pageNum) + } + continue + } + + const pageNum = getPositiveInteger(value) + if (pageNum) pageNumbers.add(pageNum) + } + + return [...pageNumbers].sort((left, right) => left - right) +} + +function getTrimmedString(value: unknown): string | null { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function getPositiveInteger(value: unknown): number | null { + return typeof value === "number" && + Number.isSafeInteger(value) && + value > 0 + ? value + : null +} + +function normalizeSourcePathCandidate( + value: string | null | undefined, +): string | null { + const trimmedValue = getTrimmedString(value) + if (!trimmedValue) return null + + const normalized = decodeUrlText(trimmedValue) + .replaceAll("\\", "/") + .replace(/\s*\/\s*/g, "/") + .replace(/\s+/g, " ") + .trim() + + return normalized.length > 0 ? normalized : null +} + +function decodeUrlText(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function formatAssetLabel( + chunk: EvidenceChunk, + sourcePath?: string | null, +): string { + const labels = [ chunk.source.sourceFileName, chunk.source.sectionPath, + sourcePath, chunk.chunkType, ] - .filter((value): value is string => Boolean(value?.trim())) - .join(" / ") + const uniqueLabels: string[] = [] + + for (const label of labels) { + const normalized = label?.trim() + if (!normalized || uniqueLabels.includes(normalized)) continue + uniqueLabels.push(normalized) + } + + return uniqueLabels.join(" / ") } function snapshot(ledger: MutableLedger): EvidenceLedgerSnapshot { diff --git a/src/agent-harness/runtime.test.ts b/src/agent-harness/runtime.test.ts index 5657e5c..0a5ca19 100644 --- a/src/agent-harness/runtime.test.ts +++ b/src/agent-harness/runtime.test.ts @@ -5,15 +5,18 @@ import { buildHarnessMessages, buildHarnessSystemPrompt, createHarnessTools, + prepareHarnessStep, + sanitizeHarnessModelMessagesForStep, } from "./runtime" import { createEvidenceLedger } from "./ledger" import type { AgentTurnInput, ContextPolicy, HarnessToolCallTrace, + ImageInspectionRequest, IntentFrame, + KnowhereToolRuntime, OutputManifest, - RetrievalCapability, } from "./types" describe("agent harness runtime", () => { @@ -26,59 +29,66 @@ describe("agent harness runtime", () => { expect(prompt).not.toContain("navigation action") }) - it("passes only outer retrieval parameters to KNOWHERE after intent and context policy are declared", async () => { - const query = vi.fn().mockResolvedValue( + it("tells the agent citation metadata is optional and ledger-resolved", () => { + const prompt = buildHarnessSystemPrompt(makeTurnInput()) + + expect(prompt).toContain( + "Citation label and source metadata are optional", + ) + expect(prompt).toContain( + "Notebook resolves citation metadata from evidence refs when possible", + ) + expect(prompt).not.toContain("must match the selected evidence ref exactly") + }) + + it("tells the agent to emit [[cite:n]] markers instead of title/pN or [1]", () => { + const prompt = buildHarnessSystemPrompt(makeTurnInput()) + + expect(prompt).toContain("[[cite:n]]") + expect(prompt).toContain("1-based index into the citations array") + expect(prompt).toContain("Do not write title/pN, [1], Markdown footnotes") + expect(prompt).toContain("Never group indices as [[cite:1, 3, 5]]") + expect(prompt).toContain("Do not collapse same-page citations") + }) + + it("requires inspectImage on cited page/image assets before finalize", () => { + const prompt = buildHarnessSystemPrompt(makeTurnInput()) + + expect(prompt).toContain( + "call inspectImage on the page/image assets you will cite before finalize", + ) + expect(prompt).toContain( + "Do not finalize cited page/image assets from chunk text alone", + ) + }) + + it("passes only outer retrieval parameters to KNOWHERE without planning-tool gating", async () => { + const query = vi.fn().mockResolvedValue( makeRetrievalResponse(), ) const state: { - intent?: IntentFrame - contextPolicy?: ContextPolicy toolCalls?: HarnessToolCallTrace[] } = {} const tools = createHarnessTools({ state, ledger: createEvidenceLedger(), - retrieval: { query }, + knowhereTools: makeKnowhereTools(query), recentTurns: [], }) - expect(await executeTool(tools.retrieve, { query: "q4 chart" })).toEqual({ - ok: false, - message: "declareIntent must be called before retrieve.", - }) - - await executeTool(tools.declareIntent, { - task: "show_media", - dependsOnPreviousTurn: false, - retrievalNeeded: "yes", - targetModalities: ["image"], - constraints: { desiredCount: 2, maxCount: 2 }, - groundingPolicy: "must_use_sources", - }) - expect(await executeTool(tools.retrieve, { query: "q4 chart" })).toEqual({ - ok: false, - message: "setContextPolicy must be called before retrieve.", - }) - - await executeTool(tools.setContextPolicy, { - carryHistory: "none", - reason: "The current request is unrelated to previous turns.", - activePriorTurnIds: [], - }) - const result = await executeTool(tools.retrieve, { + const result = await executeTool(tools.knowhere_search, { query: "q4 chart", - modalities: ["image"], + targetContent: "image", topK: 2, purpose: "Find the two requested charts.", }) - expect(result).toMatchObject({ - ok: true, - retrievalCount: 1, - }) + expect(result).toContain('') + expect(result).toContain('ref="r1:result:1"') + expect(result).toContain('ref="asset:r1:result:1"') expect(query).toHaveBeenCalledWith({ query: "q4 chart", - modalities: ["image"], + targetContent: "image", topK: 2, purpose: "Find the two requested charts.", signalPaths: undefined, @@ -89,25 +99,500 @@ describe("agent harness runtime", () => { "LegalAction", ) expect(state.toolCalls?.map((call) => [call.tool, call.ok])).toEqual([ - ["retrieve", false], - ["declareIntent", true], - ["retrieve", false], - ["setContextPolicy", true], - ["retrieve", true], + ["knowhere_search", true], ]) }) - it("blocks finalize until intent and context policy are declared", async () => { + it("returns only newly searched evidence in each knowhere_search tool result", async () => { + const query = vi + .fn() + .mockResolvedValueOnce(makeRetrievalResponse()) + .mockResolvedValueOnce({ + ...makeRetrievalResponse(), + query: "second query", + evidenceText: "Second evidence", + results: [ + { + content: "Second retrieval evidence.", + chunkType: "text", + score: 0.8, + source: { + documentId: "doc_2", + sourceFileName: "second.pdf", + sectionPath: "Second", + }, + }, + ], + }) const state: { intent?: IntentFrame contextPolicy?: ContextPolicy + toolCalls?: HarnessToolCallTrace[] + } = { + intent: { + task: "answer", + dependsOnPreviousTurn: false, + retrievalNeeded: "yes", + targetModalities: ["text"], + constraints: {}, + groundingPolicy: "must_use_sources", + }, + contextPolicy: { + carryHistory: "none", + reason: "Self-contained request.", + activePriorTurnIds: [], + }, + } + const ledger = createEvidenceLedger() + const tools = createHarnessTools({ + state, + ledger, + knowhereTools: makeKnowhereTools(query), + recentTurns: [], + }) + + const firstResult = await executeTool(tools.knowhere_search, { query: "first" }) + const secondResult = await executeTool(tools.knowhere_search, { query: "second" }) + + expect(firstResult).toContain('ref="r1:result:1"') + expect(secondResult).toContain('retrievalCount="2"') + expect(secondResult).toContain("Second evidence") + expect(secondResult).toContain('ref="r2:result:1"') + expect(JSON.stringify(secondResult)).not.toContain("r1:result:1") + expect(ledger.snapshot().chunks.map((chunk) => chunk.ref)).toEqual([ + "r1:result:1", + "r2:result:1", + ]) + }) + + it("rejects image inspection before retrieval has returned image assets", async () => { + const inspectImages = vi.fn() + const tools = createHarnessTools({ + state: {}, + ledger: createEvidenceLedger(), + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const result = await executeTool(tools.inspectImage, { + refs: ["asset:r1:result:1"], + question: "What text is visible?", + }) + + expect(result).toEqual({ + ok: false, + message: + "Knowhere evidence tools must return image assets before inspectImage.", + inspected: [], + skipped: [ + { + ref: "asset:r1:result:1", + reason: "No Knowhere evidence is available yet.", + }, + ], + }) + expect(inspectImages).not.toHaveBeenCalled() + }) + + it("rejects image inspection refs that are unknown or not image assets", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makeTableRetrievalResponse()) + const inspectImages = vi.fn() + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const result = await executeTool(tools.inspectImage, { + refs: ["asset:r1:result:1", "missing"], + question: "What is in these images?", + }) + + expect(result).toEqual({ + ok: false, + message: "No inspectable image asset refs were provided.", + inspected: [], + skipped: [ + { + ref: "asset:r1:result:1", + reason: "Ref is not an image asset.", + }, + { + ref: "missing", + reason: "Ref was not returned by Knowhere as an asset.", + }, + ], + }) + expect(inspectImages).not.toHaveBeenCalled() + }) + + it("lets retrieval bound the number of images inspected in one call", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makeImageRetrievalResponse(7)) + const inspectImages = vi.fn(async (request: { + readonly assets: readonly { readonly ref: string; readonly label: string }[] + }) => ({ + analysis: "Compared all retrieved images.", + inspected: request.assets.map(({ ref, label }) => ({ ref, label })), + skipped: [], + })) + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const result = await executeTool(tools.inspectImage, { + refs: Array.from({ length: 7 }, (_, index) => `asset:r1:result:${index + 1}`), + question: "Compare these images.", + }) + + expect(result).toMatchObject({ ok: true }) + expect(inspectImages.mock.calls[0]?.[0].assets).toHaveLength(7) + }) + + it("calls the visual inspection capability with retrieved image ledger assets", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makeRetrievalResponse()) + const inspectImages = vi.fn().mockResolvedValue({ + analysis: "The image shows a Q4 revenue chart with a rising line.", + inspected: [ + { + ref: "asset:r1:result:1", + label: "report.pdf / images/chart.png / image", + }, + ], + skipped: [], + }) + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const result = await executeTool(tools.inspectImage, { + refs: ["asset:r1:result:1"], + question: "What does the chart show?", + }) + + expect(inspectImages).toHaveBeenCalledWith({ + question: "What does the chart show?", + assets: [ + { + ref: "asset:r1:result:1", + label: "report.pdf / images/chart.png / image", + assetUrl: "https://assets.example/chart.png", + sourcePath: "images/chart.png", + source: { + documentId: "doc_1", + sourceFileName: "report.pdf", + sectionPath: "images/chart.png", + }, + }, + ], + }) + expect(result).toEqual({ + ok: true, + analysis: "The image shows a Q4 revenue chart with a rising line.", + inspected: [ + { + ref: "asset:r1:result:1", + label: "report.pdf / images/chart.png / image", + }, + ], + skipped: [], + }) + }) + + it("calls the visual inspection capability with retrieved page citation assets", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makePageCitationRetrievalResponse()) + const inspectImages = vi.fn().mockResolvedValue({ + analysis: "The clause says the contractor pays 5000 yuan per occurrence.", + inspected: [ + { + ref: "asset:r1:referenced:1", + label: + "Root / (6)现场工期进度管理方面的违约责任 / page_citation_assets/page-8.png / page", + }, + ], + skipped: [], + }) + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const result = await executeTool(tools.inspectImage, { + refs: ["asset:r1:referenced:1"], + question: "What liquidated damages amount is visible in this clause?", + }) + + expect(inspectImages).toHaveBeenCalledWith({ + question: "What liquidated damages amount is visible in this clause?", + assets: [ + { + ref: "asset:r1:referenced:1", + label: + "Root / (6)现场工期进度管理方面的违约责任 / page_citation_assets/page-8.png / page", + assetUrl: "https://assets.example/page-8.png", + sourcePath: "page_citation_assets/page-8.png", + revisionKey: "job_contract", + source: { + documentId: "doc_contract", + sourceFileName: null, + sectionPath: "Root / (6)现场工期进度管理方面的违约责任", + }, + }, + ], + }) + expect(result).toMatchObject({ + ok: true, + analysis: "The clause says the contractor pays 5000 yuan per occurrence.", + }) + }) + + it("inspects duplicate retrieved pages only once across namespaces", async () => { + const ledger = createEvidenceLedger() + const pageMetadata = { + pageNums: [4], + pageAssets: [ + { + pageNum: 4, + artifactRef: "page_citation_assets/page-4.png", + assetUrl: "https://assets.example/page-4.png", + contentType: "image/png", + }, + ], + } + ledger.addRetrievalResponse({ + namespace: "default,notebook", + query: "revenue", + routerUsed: "mapnav", + answerText: null, + evidenceText: "Revenue evidence", + stopReason: "completed", + failureReason: null, + results: [ + { + chunkId: "chunk_page_4", + content: "Revenue was $24.9B.", + chunkType: "page", + score: 0.9, + metadata: pageMetadata, + source: { + documentId: "doc_catalog", + sourceFileName: "original.pdf", + sectionPath: "FINANCIAL SUMMARY", + }, + }, + { + chunkId: "chunk_page_4", + content: "Revenue was $24.9B.", + chunkType: "page", + score: 0.9, + metadata: pageMetadata, + source: { + documentId: "doc_workspace", + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "FINANCIAL SUMMARY", + }, + }, + ], + referencedChunks: [], + }) + const inspectImages = vi.fn().mockResolvedValue({ + analysis: "Revenue is visible in the financial summary.", + inspected: [ + { + ref: "asset:r1:result:1", + label: "original.pdf / page_citation_assets/page-4.png / page", + }, + ], + skipped: [], + highlights: [ + { + ref: "asset:r1:result:1", + regions: [{ x: 0.1, y: 0.2, w: 0.8, h: 0.1 }], + }, + ], + }) + const state: { + inspectedImageRefs?: string[] + } = {} + const tools = createHarnessTools({ + state, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + const inspection = await executeTool(tools.inspectImage, { + refs: ["asset:r1:result:1", "asset:r1:result:2"], + question: "Locate the cited revenue.", + }) + + expect(inspection).toMatchObject({ ok: true }) + expect(inspectImages.mock.calls[0]?.[0].assets).toHaveLength(1) + expect( + await executeTool(tools.finalize, { + text: "Revenue was $24.9B [[cite:1]] [[cite:2]].", + citations: [{ ref: "r1:result:1" }, { ref: "r1:result:2" }], + artifacts: [], + unresolved: [], + }), + ).toMatchObject({ ok: true }) + }) + + it("does not deduplicate unrelated documents with generic page paths", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse({ + namespace: "notebook", + query: "revenue", + routerUsed: "mapnav", + answerText: null, + evidenceText: "Revenue evidence", + stopReason: "completed", + failureReason: null, + results: [ + { + chunkId: "chunk_page_4", + content: "Revenue evidence from document A.", + chunkType: "page", + score: 0.9, + metadata: { + pageNums: [4], + pageAssets: [ + { + pageNum: 4, + artifactRef: "page_citation_assets/page-4.png", + assetUrl: "https://assets.example/doc-a/page-4.png", + contentType: "image/png", + }, + ], + }, + source: { + documentId: "doc_a", + sourceFileName: "a.pdf", + sectionPath: "Page 4", + }, + }, + { + chunkId: "chunk_page_4", + content: "Revenue evidence from document B.", + chunkType: "page", + score: 0.8, + metadata: { + pageNums: [4], + pageAssets: [ + { + pageNum: 4, + artifactRef: "page_citation_assets/page-4.png", + assetUrl: "https://assets.example/doc-b/page-4.png", + contentType: "image/png", + }, + ], + }, + source: { + documentId: "doc_b", + sourceFileName: "b.pdf", + sectionPath: "Page 4", + }, + }, + ], + referencedChunks: [], + }) + const inspectImages = vi.fn(async (request: ImageInspectionRequest) => ({ + analysis: "Inspected both pages.", + inspected: request.assets.map(({ ref, label }) => ({ ref, label })), + skipped: [], + })) + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages, + recentTurns: [], + }) + + expect( + await executeTool(tools.inspectImage, { + refs: ["asset:r1:result:1", "asset:r1:result:2"], + question: "Compare the cited revenue.", + }), + ).toMatchObject({ ok: true }) + expect(inspectImages.mock.calls[0]?.[0].assets).toHaveLength(2) + }) + + it("does not treat skipped image assets as successfully inspected", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makePageCitationRetrievalResponse()) + const state: { + inspectedImageRefs?: string[] + finalized?: boolean + } = {} + const tools = createHarnessTools({ + state, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages: vi.fn().mockResolvedValue({ + analysis: "", + inspected: [], + skipped: [ + { + ref: "asset:r1:referenced:1", + reason: "The image asset was unavailable in Notebook storage.", + }, + ], + }), + recentTurns: [], + }) + + const inspection = await executeTool(tools.inspectImage, { + refs: ["asset:r1:referenced:1"], + question: "Locate the cited amount.", + }) + + expect(inspection).toMatchObject({ + ok: false, + inspected: [], + }) + expect(state.inspectedImageRefs).toEqual([]) + + const finalize = await executeTool(tools.finalize, { + text: "The amount is 5000 yuan [[cite:1]].", + citations: [{ ref: "r1:referenced:1" }], + artifacts: [], + unresolved: [], + }) + expect(finalize).toMatchObject({ + ok: false, + inspectRefs: ["asset:r1:referenced:1"], + }) + expect(state.finalized).not.toBe(true) + }) + + it("accepts finalize output without planning-tool gating", async () => { + const state: { finalizedManifest?: OutputManifest finalized?: boolean } = {} const tools = createHarnessTools({ state, ledger: createEvidenceLedger(), - retrieval: { query: vi.fn() }, + knowhereTools: makeKnowhereTools(), recentTurns: [], }) @@ -118,39 +603,112 @@ describe("agent harness runtime", () => { unresolved: [], } - expect(await executeTool(tools.finalize, manifest)).toEqual({ - ok: false, - message: "declareIntent must be called before finalize.", + expect(await executeTool(tools.finalize, manifest)).toMatchObject({ + ok: true, + text: "Answer.", }) - expect(state.finalizedManifest).toBeUndefined() + expect(state.finalizedManifest).toEqual(manifest) + expect(state.finalized).toBe(true) + }) - await executeTool(tools.declareIntent, { - task: "answer_question", - dependsOnPreviousTurn: false, - retrievalNeeded: "no", - targetModalities: ["text"], - constraints: {}, - groundingPolicy: "may_use_sources", + it("rejects finalize of cited page images until inspectImage has run", async () => { + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse(makePageCitationRetrievalResponse()) + const state: { + finalizedManifest?: OutputManifest + finalized?: boolean + inspectedImageRefs?: string[] + } = {} + const tools = createHarnessTools({ + state, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages: vi.fn().mockResolvedValue({ + analysis: "The clause shows 5000 yuan per occurrence.", + inspected: [{ ref: "asset:r1:referenced:1", label: "page 8" }], + skipped: [], + highlights: [ + { + ref: "asset:r1:referenced:1", + regions: [{ x: 0.1, y: 0.2, w: 0.4, h: 0.15 }], + }, + ], + }), + recentTurns: [], }) - expect(await executeTool(tools.finalize, manifest)).toEqual({ + const manifest = { + text: "The contractor pays 5000 yuan per occurrence [[cite:1]].", + citations: [{ ref: "r1:referenced:1" }], + artifacts: [], + unresolved: [], + } + + const blocked = await executeTool(tools.finalize, manifest) + + expect(blocked).toMatchObject({ ok: false, - message: "setContextPolicy must be called before finalize.", + inspectRefs: ["asset:r1:referenced:1"], }) - expect(state.finalizedManifest).toBeUndefined() + expect(blocked).toEqual( + expect.objectContaining({ + message: expect.stringContaining("inspectImage"), + }), + ) + expect(state.finalized).not.toBe(true) - await executeTool(tools.setContextPolicy, { - carryHistory: "none", - reason: "Self-contained request.", - activePriorTurnIds: [], + await executeTool(tools.inspectImage, { + refs: ["asset:r1:referenced:1"], + question: "Locate the cited liquidated-damages amount on this page.", }) + expect(await executeTool(tools.finalize, manifest)).toMatchObject({ ok: true, - text: "Answer.", + text: manifest.text, }) - expect(state.finalizedManifest).toEqual(manifest) expect(state.finalized).toBe(true) }) + it("requires inspection when the cited result shares a page asset with a referenced chunk", async () => { + const response = makePageCitationRetrievalResponse() + const ledger = createEvidenceLedger() + ledger.addRetrievalResponse({ + ...response, + results: [ + { + chunkId: "chunk_page_8", + content: "The contractor pays 5000 yuan per occurrence.", + chunkType: "page", + score: 0.9, + metadata: { pageNums: [8] }, + source: { + documentId: "doc_contract", + sourceFileName: "contract.pdf", + sectionPath: "Root / Liquidated damages", + }, + }, + ], + }) + const tools = createHarnessTools({ + state: {}, + ledger, + knowhereTools: makeKnowhereTools(), + inspectImages: vi.fn(), + recentTurns: [], + }) + + const result = await executeTool(tools.finalize, { + text: "The contractor pays 5000 yuan [[cite:1]].", + citations: [{ ref: "r1:result:1" }], + artifacts: [], + unresolved: [], + }) + + expect(result).toMatchObject({ + ok: false, + inspectRefs: ["asset:r1:referenced:1"], + }) + }) + it("exposes full prior-turn content through policy-approved readPriorTurn", async () => { const state: { contextPolicy?: ContextPolicy @@ -166,7 +724,7 @@ describe("agent harness runtime", () => { const tools = createHarnessTools({ state, ledger: createEvidenceLedger(), - retrieval: { query: vi.fn() }, + knowhereTools: makeKnowhereTools(), recentTurns: [ { id: "turn_1", @@ -198,7 +756,7 @@ describe("agent harness runtime", () => { const tools = createHarnessTools({ state, ledger: createEvidenceLedger(), - retrieval: { query: vi.fn() }, + knowhereTools: makeKnowhereTools(), recentTurns: [ { id: "turn_1", @@ -251,12 +809,242 @@ describe("agent harness runtime", () => { expect(JSON.stringify(messages)).toContain("id=turn_1 role=assistant") expect(JSON.stringify(messages)).not.toContain("searchSources.query") }) + + it("removes provider metadata from tool-result parts while preserving tool-call metadata", () => { + const messages = sanitizeHarnessModelMessagesForStep([ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "knowhere_search", + input: { query: "q4" }, + providerOptions: { + google: { + thoughtSignature: "signature-1", + }, + }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: '', + }, + providerOptions: { + google: { + thoughtSignature: "signature-1", + }, + }, + }, + ], + }, + ]) + + expect(messages).toEqual([ + { + role: "assistant", + content: [ + expect.objectContaining({ + type: "tool-call", + providerOptions: { + google: { + thoughtSignature: "signature-1", + }, + }, + }), + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: '', + }, + }, + ], + }, + ]) + }) + + it("keeps normal steps unconstrained before the finalization step", () => { + const result = prepareHarnessStep({ + stepNumber: 11, + messages: [ + { + role: "user", + content: "Find the penalty amount.", + }, + ], + }) + + expect(result).toEqual({ + messages: [ + { + role: "user", + content: "Find the penalty amount.", + }, + ], + }) + }) + + it("forces image inspection before forced finalization when image assets are available", () => { + const result = prepareHarnessStep({ + stepNumber: 12, + hasUninspectedImageAssets: true, + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: + '', + }, + }, + ], + }, + ], + }) + + expect(result.activeTools).toEqual(["inspectImage"]) + expect(result.toolChoice).toEqual({ + type: "tool", + toolName: "inspectImage", + }) + expect(result.messages).toEqual([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: + '', + }, + }, + ], + }, + { + role: "user", + content: expect.stringContaining("Call inspectImage now"), + }, + ]) + }) + + it("reserves the finalization step even when unused page assets remain", () => { + const result = prepareHarnessStep({ + stepNumber: 13, + hasUninspectedImageAssets: true, + messages: [ + { + role: "user", + content: "What is the penalty amount?", + }, + ], + }) + + expect(result.activeTools).toEqual(["finalize"]) + expect(result.toolChoice).toEqual({ + type: "tool", + toolName: "finalize", + }) + }) + + it("forces finalize at step 13 using existing tool results", () => { + const result = prepareHarnessStep({ + stepNumber: 13, + messages: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: + '', + }, + providerOptions: { + google: { + thoughtSignature: "signature-1", + }, + }, + }, + ], + }, + ], + }) + + expect(result.activeTools).toEqual(["finalize"]) + expect(result.toolChoice).toEqual({ + type: "tool", + toolName: "finalize", + }) + expect(result.messages).toEqual([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "knowhere_search", + output: { + type: "text", + value: + '', + }, + }, + ], + }, + { + role: "user", + content: expect.stringContaining( + "Use only the evidence and tool results already available", + ), + }, + ]) + }) }) function executeTool(tool: unknown, input: unknown): Promise { return (tool as { execute: (input: unknown) => Promise }).execute(input) } +function makeKnowhereTools( + search: KnowhereToolRuntime["search"] = vi.fn(), +): KnowhereToolRuntime { + return { + search, + listDocuments: vi.fn().mockResolvedValue({ documents: [] }), + getDocumentOutline: vi.fn().mockRejectedValue(new Error("Not configured.")), + readChunks: vi.fn().mockRejectedValue(new Error("Not configured.")), + grepChunks: vi.fn().mockRejectedValue(new Error("Not configured.")), + } +} + function makeTurnInput(overrides: Partial = {}): AgentTurnInput { return { surface: "notebook_chat", @@ -296,3 +1084,88 @@ function makeRetrievalResponse(): RetrievalQueryResponse { referencedChunks: [], } } + +function makeTableRetrievalResponse(): RetrievalQueryResponse { + return { + namespace: "notebook", + query: "q4 table", + routerUsed: "workflow_single_step", + answerText: null, + evidenceText: "Table evidence", + stopReason: "answer_done", + failureReason: null, + results: [ + { + content: "", + chunkType: "table", + score: 0.8, + assetUrl: "https://assets.example/tables/revenue.html", + source: { + documentId: "doc_1", + sourceFileName: "report.pdf", + sectionPath: "tables/revenue.html", + }, + }, + ], + referencedChunks: [], + } +} + +function makeImageRetrievalResponse(count: number): RetrievalQueryResponse { + return { + namespace: "notebook", + query: "q4 images", + routerUsed: "workflow_single_step", + answerText: null, + evidenceText: "Image evidence", + stopReason: "answer_done", + failureReason: null, + results: Array.from({ length: count }, (_, index) => ({ + content: "", + chunkType: "image", + score: 0.8, + assetUrl: `https://assets.example/images/chart-${index + 1}.png`, + source: { + documentId: "doc_1", + sourceFileName: "report.pdf", + sectionPath: `images/chart-${index + 1}.png`, + }, + })), + referencedChunks: [], + } +} + +function makePageCitationRetrievalResponse(): RetrievalQueryResponse { + return { + namespace: "notebook", + query: "进度计划", + routerUsed: "workflow_single_step", + answerText: null, + evidenceText: "Root / (6)现场工期进度管理方面的违约责任", + stopReason: "answer_done", + failureReason: null, + results: [], + referencedChunks: [ + { + chunkId: "chunk_page_8", + documentId: "doc_contract", + chunkType: "page", + sectionPath: "Root / (6)现场工期进度管理方面的违约责任", + filePath: null, + jobId: "job_contract", + assetUrl: "https://assets.example/page-8.png", + metadata: { + pageNums: [8], + pageAssets: [ + { + pageNum: 8, + artifactRef: "page_citation_assets/page-8.png", + assetUrl: "https://assets.example/page-8.png", + contentType: "image/png", + }, + ], + }, + }, + ], + } +} diff --git a/src/agent-harness/runtime.ts b/src/agent-harness/runtime.ts index c03baac..073051d 100644 --- a/src/agent-harness/runtime.ts +++ b/src/agent-harness/runtime.ts @@ -1,23 +1,38 @@ -import { stepCountIs, ToolLoopAgent, tool, type ModelMessage } from "ai" +import { + stepCountIs, + ToolLoopAgent, + tool, + type ModelMessage, + type ToolResultPart, +} from "ai" import { z } from "zod" import { createEvidenceLedger } from "./ledger" +import { getCanonicalImageAssetKey } from "./image-asset-identity" +import { knowhereToolText } from "./knowhere-text" +import { mergeImageInspectionHighlights } from "./image-highlights" import type { AgentTurn, AgentTurnInput, ContextPolicy, + EvidenceAsset, + EvidenceChunk, HarnessRunResult, HarnessToolCallTrace, HarnessTrace, + ImageInspectionAsset, + ImageInspectionHighlights, + ImageInspectionResponse, + InspectImages, IntentFrame, + KnowhereSearchTargetContent, + KnowhereToolRuntime, OutputManifest, - RetrievalCapability, - TargetModality, } from "./types" -import { validateOutputManifest } from "./validator" -const defaultMaxSteps = 10 -const defaultMaxRevisions = 1 +const defaultMaxSteps = 14 +const imageInspectionReminderStepNumber = 12 +const forcedFinalizationStepNumber = 13 type ToolLoopAgentSettings = ConstructorParameters[0] @@ -26,13 +41,9 @@ export type AgentHarnessModel = ToolLoopAgentSettings["model"] export type RunAgentHarnessInput = { readonly model: AgentHarnessModel readonly turn: AgentTurnInput - readonly retrieval: RetrievalCapability + readonly knowhereTools: KnowhereToolRuntime + readonly inspectImages?: InspectImages readonly maxSteps?: number - /** - * How many times the agent may revise after a failed validation pass before - * the harness gives up and returns the last manifest with recorded errors. - */ - readonly maxRevisions?: number } type HarnessToolState = { @@ -41,10 +52,70 @@ type HarnessToolState = { finalizedManifest?: OutputManifest finalized?: boolean priorTurnReads?: string[] + inspectedImageRefs?: string[] + imageHighlights?: ImageInspectionHighlights[] toolCalls?: HarnessToolCallTrace[] } +type HarnessTools = ReturnType + +type HarnessStepPreparation = { + messages: ModelMessage[] + activeTools?: Array> + toolChoice?: { + type: "tool" + toolName: Extract + } +} + const targetModalitySchema = z.enum(["text", "image", "table"]) +const knowhereSearchTargetContentSchema = z.enum([ + "all", + "text", + "image", + "table", + "text_image", + "text_table", +]) +const knowledgeChunkTypeSchema = z.enum(["text", "image", "table", "page"]) + +const knowhereDocumentReferenceSchema = z.object({ + localDocumentId: z.string().min(1).optional(), + documentId: z.string().min(1).optional(), + jobId: z.string().min(1).optional(), + revisionKey: z.string().min(1).optional(), +}) + +const knowhereSearchSchema = z.object({ + query: z.string().min(1), + targetContent: knowhereSearchTargetContentSchema.default("all"), + purpose: z.string().optional(), + topK: z.number().int().min(1).max(12).optional(), + signalPaths: z.array(z.string().min(1)).max(8).optional(), + filterMode: z.enum(["keep", "delete"]).optional(), + threshold: z.number().min(0).max(1).optional(), +}) + +const knowhereReadChunksSchema = knowhereDocumentReferenceSchema.extend({ + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(50).optional(), + sectionPath: z.string().min(1).optional(), + startChunk: z.number().int().min(0).optional(), + endChunk: z.number().int().min(0).optional(), + chunkId: z.string().min(1).optional(), + chunkType: knowledgeChunkTypeSchema.optional(), +}) + +const knowhereGrepChunksSchema = knowhereDocumentReferenceSchema.extend({ + pattern: z.string().min(1), + continuationCursor: z.string().min(1).optional(), + isRegex: z.boolean().optional(), + isCaseSensitive: z.boolean().optional(), + maxResults: z.number().int().min(1).max(50).optional(), + chunkType: knowledgeChunkTypeSchema.optional(), + sectionPathPrefix: z.string().min(1).optional(), + contextChars: z.number().int().min(0).max(2_000).optional(), +}) const intentFrameSchema = z.object({ task: z.enum([ @@ -90,12 +161,14 @@ const contextPolicySchema = z.object({ const outputCitationSchema = z.object({ ref: z.string().min(1), - label: z.string().min(1), - source: z.object({ - documentId: z.string().nullable().optional(), - sourceFileName: z.string().nullable().optional(), - sectionPath: z.string().nullable().optional(), - }), + label: z.string().min(1).optional(), + source: z + .object({ + documentId: z.string().nullable().optional(), + sourceFileName: z.string().nullable().optional(), + sectionPath: z.string().nullable().optional(), + }) + .optional(), }) const selectedOutputArtifactSchema = z.object({ @@ -140,55 +213,33 @@ export async function runAgentHarness( const tools = createHarnessTools({ state, ledger, - retrieval: input.retrieval, + knowhereTools: input.knowhereTools, + inspectImages: input.inspectImages, recentTurns: input.turn.recentTurns, }) const agent = new ToolLoopAgent({ model: input.model, instructions: buildHarnessSystemPrompt(input.turn), tools, - stopWhen: stepCountIs(input.maxSteps ?? defaultMaxSteps), + prepareStep: ({ messages: stepMessages, stepNumber }) => + prepareHarnessStep({ + messages: stepMessages, + stepNumber, + hasUninspectedImageAssets: + input.inspectImages !== undefined && + hasUninspectedImageAssets({ state, ledger }), + }), + stopWhen: [ + () => state.finalized === true, + stepCountIs(input.maxSteps ?? defaultMaxSteps), + ], }) - const maxRevisions = input.maxRevisions ?? defaultMaxRevisions - let messages = buildHarnessMessages(input.turn) - let manifest = buildFallbackManifest("") - let validationErrors: readonly string[] = [] - let revisionsUsed = 0 - - for (let attempt = 0; ; attempt += 1) { - const response = await agent.generate({ messages }) - manifest = - state.finalizedManifest ?? buildFallbackManifest(response.text.trim()) - - const validation = validateOutputManifest({ - manifest, - intent: state.intent, - contextPolicy: state.contextPolicy, - finalized: state.finalized === true, - ledger: ledger.snapshot(), - surface: input.turn.surface, - }) - validationErrors = validation.errors - - if (validation.ok || attempt >= maxRevisions) break - - // Self-correction (reflexion): continue the same conversation with - // structured validator feedback and require a fresh finalize so the agent - // can repair its own contract violations instead of shipping them. - revisionsUsed += 1 - state.finalizedManifest = undefined - state.finalized = false - messages = [ - ...messages, - ...(response.response.messages as ModelMessage[]), - { - role: "user", - content: buildRevisionFeedback(validation.errors), - }, - ] - } - + const response = await agent.generate({ + messages: buildHarnessMessages(input.turn), + }) + const manifest = + state.finalizedManifest ?? buildFallbackManifest(response.text.trim()) const ledgerSnapshot = ledger.snapshot() return { manifest, @@ -199,36 +250,173 @@ export async function runAgentHarness( finalized: state.finalized === true, priorTurnReads: [...(state.priorTurnReads ?? [])], toolCalls: [...(state.toolCalls ?? [])], - validationErrors, - revisionsUsed, + imageHighlights: [...(state.imageHighlights ?? [])], + validationErrors: [], + revisionsUsed: 0, }, } } -function buildRevisionFeedback(errors: readonly string[]): string { +export function prepareHarnessStep(input: { + readonly stepNumber: number + readonly messages: readonly ModelMessage[] + readonly hasUninspectedImageAssets?: boolean +}): HarnessStepPreparation { + const messages = sanitizeHarnessModelMessagesForStep(input.messages) + + const shouldForceImageInspection = + input.hasUninspectedImageAssets === true && + input.stepNumber === imageInspectionReminderStepNumber + + if (shouldForceImageInspection) { + return { + messages: [ + ...messages, + { + role: "user", + content: buildImageInspectionReminderFeedback(), + }, + ], + activeTools: ["inspectImage"], + toolChoice: { + type: "tool", + toolName: "inspectImage", + }, + } + } + + if (input.stepNumber < forcedFinalizationStepNumber) { + return { messages } + } + + return { + messages: [ + ...messages, + { + role: "user", + content: buildForcedFinalizationFeedback(), + }, + ], + activeTools: ["finalize"], + toolChoice: { + type: "tool", + toolName: "finalize", + }, + } +} + +export function sanitizeHarnessModelMessagesForStep( + messages: readonly ModelMessage[], +): ModelMessage[] { + return messages.map(sanitizeHarnessModelMessageForStep) +} + +function sanitizeHarnessModelMessageForStep( + message: ModelMessage, +): ModelMessage { + if (message.role === "tool") { + return { + ...message, + providerOptions: undefined, + content: message.content.map(sanitizeToolMessageContentPart), + } + } + + if (message.role === "assistant" && Array.isArray(message.content)) { + return { + ...message, + content: message.content.map(sanitizeAssistantMessageContentPart), + } + } + + return message +} + +function sanitizeToolMessageContentPart( + part: ModelMessageForRole<"tool">["content"][number], +): ModelMessageForRole<"tool">["content"][number] { + if (part.type !== "tool-result") return part + + return removeToolResultPartProviderOptions(part) +} + +function sanitizeAssistantMessageContentPart( + part: Exclude["content"], string>[number], +): Exclude["content"], string>[number] { + if (part.type !== "tool-result") return part + + return removeToolResultPartProviderOptions(part) +} + +function removeToolResultPartProviderOptions( + part: ToolResultPart, +): ToolResultPart { + const sanitizedPart = { ...part } + delete sanitizedPart.providerOptions + return sanitizedPart +} + +type ModelMessageForRole = Extract< + ModelMessage, + { readonly role: TRole } +> + +function buildForcedFinalizationFeedback(): string { return [ - "Your finalize output did not satisfy the output contract:", - ...errors.map((error) => `- ${error}`), - "", - "Fix every issue and call finalize again with a corrected manifest.", - "You must call finalize; freeform assistant text is not a valid final", - "answer contract.", - "Do not exceed the user's requested artifact count, only cite or display", - "evidence refs that exist in the evidence ledger, and do not fabricate", - "facts when evidence is missing.", + "The retrieval step budget has been reached.", + "Do not search again or call any Knowhere evidence-reading tools.", + "Use only the evidence and tool results already available in this turn.", + "Call finalize now with the best supported answer.", + "If the existing evidence is insufficient, explain the gap in unresolved", + "instead of making unsupported claims.", ].join("\n") } +function buildImageInspectionReminderFeedback(): string { + return [ + "Retrieved image/page assets are available and have not been inspected.", + "Call inspectImage now with the page/image asset refs you will cite.", + "Use a question that locates the cited evidence on those pages for OCR/visual context and provenance boxes.", + "Do not finalize until those cited image assets have been inspected.", + "Do not search again.", + ].join("\n") +} + +function hasUninspectedImageAssets(input: { + readonly state: HarnessToolState + readonly ledger: ReturnType +}): boolean { + const snapshot = input.ledger.snapshot() + const chunksByRef = new Map( + snapshot.chunks.map((chunk) => [chunk.ref, chunk] as const), + ) + const assetsByRef = new Map( + snapshot.assets.map((asset) => [asset.ref, asset] as const), + ) + const inspectedKeys = new Set( + (input.state.inspectedImageRefs ?? []).map((ref) => { + const asset = assetsByRef.get(ref) + return asset ? getCanonicalImageAssetKey(asset, chunksByRef) : ref + }), + ) + return snapshot.assets.some( + (asset) => + asset.type === "image" && + !inspectedKeys.has(getCanonicalImageAssetKey(asset, chunksByRef)), + ) +} + export function createHarnessTools(input: { readonly state: HarnessToolState readonly ledger: ReturnType - readonly retrieval: RetrievalCapability + readonly knowhereTools: KnowhereToolRuntime + readonly inspectImages?: InspectImages readonly recentTurns: readonly AgentTurn[] }) { return { declareIntent: tool({ description: - "Declare the user's intent before any other action. This is working memory, not a final answer.", + "Declare the user's intent when it helps plan the response. This is working memory, not a final answer.", inputSchema: intentFrameSchema, execute: async (intent): Promise => traceToolCall(input.state, { @@ -258,95 +446,121 @@ export function createHarnessTools(input: { }), }), - retrieve: tool({ + knowhere_search: tool({ description: - "Ask KNOWHERE for evidence context. KNOWHERE handles internal navigation; this tool only submits a concise query and records returned evidence.", - inputSchema: z.object({ - query: z.string().min(1), - modalities: z.array(targetModalitySchema).default(["text"]), - purpose: z.string().optional(), - topK: z.number().int().min(1).max(12).optional(), - signalPaths: z.array(z.string().min(1)).max(8).optional(), - filterMode: z.enum(["keep", "delete"]).optional(), - threshold: z.number().min(0).max(1).optional(), - }), + "Search Knowhere for relevant Notebook evidence. Returns tagged text with evidence refs such as r1:result:1 and asset refs such as asset:r1:result:1.", + inputSchema: knowhereSearchSchema, execute: async (request) => traceToolCall(input.state, { - toolName: "retrieve", - inputSummary: summarizeRetrievalRequest(request), - execute: async () => { - if (!input.state.intent) { - return { - ok: false, - message: "declareIntent must be called before retrieve.", - } - } - if (!input.state.contextPolicy) { - return { - ok: false, - message: "setContextPolicy must be called before retrieve.", - } - } + toolName: "knowhere_search", + inputSummary: summarizeKnowhereSearchRequest(request), + execute: async () => + executeKnowhereSearch({ + ledger: input.ledger, + knowhereTools: input.knowhereTools, + request, + }), + summarizeOutput: summarizeKnowhereTextOutput, + }), + }), - const response = await input.retrieval.query({ - query: request.query, - modalities: request.modalities as TargetModality[], - purpose: request.purpose, - topK: request.topK, - signalPaths: request.signalPaths, - filterMode: request.filterMode, - threshold: request.threshold, - }) - const snapshot = input.ledger.addRetrievalResponse(response) - return { - ok: true, - retrievalCount: snapshot.retrievalCount, - evidenceText: response.evidenceText ?? "", - stopReason: response.stopReason ?? null, - failureReason: response.failureReason ?? null, - chunks: snapshot.chunks.map((chunk) => ({ - ref: chunk.ref, - kind: chunk.kind, - type: chunk.chunkType, - preview: chunk.contentPreview, - source: chunk.source, - assetRef: chunk.assetRef, - })), - assets: snapshot.assets.map((asset) => ({ - ref: asset.ref, - type: asset.type, - label: asset.label, - source: asset.source, - })), - } - }, - summarizeOutput: summarizeRetrieveOutput, + knowhere_list_documents: tool({ + description: + "List ready visible Notebook/Knowhere documents available for this chat turn. Use this to discover documentId and revisionKey before outline/read/grep.", + inputSchema: z.object({}), + execute: async () => + traceToolCall(input.state, { + toolName: "knowhere_list_documents", + inputSummary: {}, + execute: async () => + executeKnowhereTextTool({ + operation: "list_documents", + execute: async () => + knowhereToolText.formatListDocuments( + await input.knowhereTools.listDocuments(), + ), + }), + summarizeOutput: summarizeKnowhereTextOutput, }), }), - readEvidence: tool({ + knowhere_get_document_outline: tool({ description: - "Read more text from an evidence chunk already returned by KNOWHERE.", + "Read a document outline from Knowhere parsed storage. Use documentId/revisionKey from knowhere_list_documents or search refs.", + inputSchema: knowhereDocumentReferenceSchema, + execute: async (request) => + traceToolCall(input.state, { + toolName: "knowhere_get_document_outline", + inputSummary: summarizeDocumentReference(request), + execute: async () => + executeKnowhereTextTool({ + operation: "get_document_outline", + validate: () => validateDocumentReference(request), + execute: async () => + knowhereToolText.formatOutline( + await input.knowhereTools.getDocumentOutline(request), + ), + }), + summarizeOutput: summarizeKnowhereTextOutput, + }), + }), + + knowhere_read_chunks: tool({ + description: + "Read complete chunk bodies from Knowhere parsed storage. This tool never slices individual chunk content; control read size with page/pageSize, sectionPath, startChunk/endChunk, chunkId, and chunkType.", + inputSchema: knowhereReadChunksSchema, + execute: async (request) => + traceToolCall(input.state, { + toolName: "knowhere_read_chunks", + inputSummary: summarizeReadChunksRequest(request), + execute: async () => + executeKnowhereReadChunks({ + ledger: input.ledger, + knowhereTools: input.knowhereTools, + request, + }), + summarizeOutput: summarizeKnowhereTextOutput, + }), + }), + + knowhere_grep_chunks: tool({ + description: + "Search chunk text with a literal or regex pattern. Returns bounded match snippets as grep refs such as grep1:match:1 and may include truncated=true with a continuationCursor.", + inputSchema: knowhereGrepChunksSchema, + execute: async (request) => + traceToolCall(input.state, { + toolName: "knowhere_grep_chunks", + inputSummary: summarizeGrepChunksRequest(request), + execute: async () => + executeKnowhereGrepChunks({ + ledger: input.ledger, + knowhereTools: input.knowhereTools, + request, + }), + summarizeOutput: summarizeKnowhereTextOutput, + }), + }), + + inspectImage: tool({ + description: + "Inspect cited Knowhere page/image asset refs for OCR, visual details, and provenance boxes. Call this after retrieval and before finalize whenever the answer cites page or image assets.", inputSchema: z.object({ - ref: z.string().min(1), - offset: z.number().int().min(0).optional(), - limit: z.number().int().min(1).max(8_000).optional(), + refs: z.array(z.string().min(1)).min(1), + question: z.string().min(1), }), execute: async (request) => traceToolCall(input.state, { - toolName: "readEvidence", - inputSummary: { - ref: request.ref, - offset: request.offset ?? 0, - limit: request.limit ?? 4_000, - }, + toolName: "inspectImage", + inputSummary: summarizeInspectImageRequest(request), execute: async () => - input.ledger.read( - request.ref, - request.offset ?? 0, - request.limit ?? 4_000, - ), - summarizeOutput: summarizeReadEvidenceOutput, + inspectRetrievedImages({ + state: input.state, + ledger: input.ledger, + inspectImages: input.inspectImages, + refs: request.refs, + question: request.question, + }), + summarizeOutput: summarizeInspectImageOutput, }), }), @@ -413,25 +627,28 @@ export function createHarnessTools(input: { description: "Finalize the user-facing output manifest. This is the only final answer " + "contract. Artifacts listed here with display=true are the exact set of " + - "images/tables shown to the user; cite only refs from the evidence ledger.", + "images/tables shown to the user; cite evidence refs when available. " + + "Cited page/image assets must be inspected with inspectImage first.", inputSchema: outputManifestSchema, execute: async (manifest) => traceToolCall(input.state, { toolName: "finalize", inputSummary: summarizeManifest(manifest), execute: async () => { - if (!input.state.intent) { - return { - ok: false as const, - message: "declareIntent must be called before finalize.", - } - } - if (!input.state.contextPolicy) { + const inspectRefs = getUninspectedCitedImageRefs({ + manifest, + ledger: input.ledger, + inspectedImageRefs: input.state.inspectedImageRefs ?? [], + inspectImagesAvailable: input.inspectImages !== undefined, + }) + if (inspectRefs.length > 0) { return { ok: false as const, - message: "setContextPolicy must be called before finalize.", + message: buildFinalizeRequiresInspectionMessage(inspectRefs), + inspectRefs, } } + input.state.finalizedManifest = manifest input.state.finalized = true return { ok: true as const, ...manifest } @@ -442,6 +659,390 @@ export function createHarnessTools(input: { } as const } +async function inspectRetrievedImages(input: { + readonly state: HarnessToolState + readonly ledger: ReturnType + readonly inspectImages?: InspectImages + readonly refs: readonly string[] + readonly question: string +}): Promise< + | ({ readonly ok: true } & ImageInspectionResponse) + | { + readonly ok: false + readonly message: string + readonly inspected: readonly [] + readonly skipped: readonly { + readonly ref: string + readonly reason: string + }[] + } +> { + const refs = getUniqueTrimmedRefs(input.refs) + const question = input.question.trim() + + if (refs.length === 0 || question.length === 0) { + return { + ok: false, + message: "At least one image asset ref and a question are required.", + inspected: [], + skipped: [], + } + } + const snapshot = input.ledger.snapshot() + if (snapshot.chunks.length === 0 && snapshot.assets.length === 0) { + return { + ok: false, + message: + "Knowhere evidence tools must return image assets before inspectImage.", + inspected: [], + skipped: refs.map((ref) => ({ + ref, + reason: "No Knowhere evidence is available yet.", + })), + } + } + if (!input.inspectImages) { + return { + ok: false, + message: "Image inspection is not available for this turn.", + inspected: [], + skipped: refs.map((ref) => ({ + ref, + reason: "No image inspection capability is configured.", + })), + } + } + + const assetsByRef = new Map( + snapshot.assets.map((asset) => [asset.ref, asset] as const), + ) + const chunksByRef = new Map( + snapshot.chunks.map((chunk) => [chunk.ref, chunk] as const), + ) + const skipped: { + readonly ref: string + readonly reason: string + }[] = [] + const selectedAssets: ImageInspectionAsset[] = [] + const selectedKeys = new Set() + + for (const ref of refs) { + const asset = assetsByRef.get(ref) + if (!asset) { + skipped.push({ + ref, + reason: "Ref was not returned by Knowhere as an asset.", + }) + continue + } + if (asset.type !== "image") { + skipped.push({ + ref, + reason: "Ref is not an image asset.", + }) + continue + } + + const assetKey = getCanonicalImageAssetKey(asset, chunksByRef) + if (selectedKeys.has(assetKey)) { + skipped.push({ + ref, + reason: "Duplicate of another retrieved page selected for inspection.", + }) + continue + } + selectedKeys.add(assetKey) + selectedAssets.push({ + ref: asset.ref, + label: asset.label, + ...(asset.assetUrl ? { assetUrl: asset.assetUrl } : {}), + ...(asset.sourcePath ? { sourcePath: asset.sourcePath } : {}), + ...(asset.revisionKey ? { revisionKey: asset.revisionKey } : {}), + source: asset.source, + }) + } + + if (selectedAssets.length === 0) { + return { + ok: false, + message: "No inspectable image asset refs were provided.", + inspected: [], + skipped, + } + } + + const inspectedImageRefs = input.state.inspectedImageRefs ?? [] + + try { + const response = await input.inspectImages({ + question, + assets: selectedAssets, + }) + const selectedRefs = new Set(selectedAssets.map((asset) => asset.ref)) + const successfulRefs = response.inspected + .map((asset) => asset.ref) + .filter((ref) => selectedRefs.has(ref)) + input.state.inspectedImageRefs = [ + ...inspectedImageRefs, + ...successfulRefs.filter((ref) => !inspectedImageRefs.includes(ref)), + ] + input.state.imageHighlights = mergeImageInspectionHighlights( + input.state.imageHighlights, + response.highlights, + ) + if (successfulRefs.length === 0) { + return { + ok: false as const, + message: "Image inspection skipped every requested asset.", + inspected: [], + skipped: [...skipped, ...response.skipped], + } + } + return { + ok: true as const, + analysis: response.analysis, + inspected: response.inspected, + skipped: [...skipped, ...response.skipped], + } + } catch (error) { + return { + ok: false, + message: + error instanceof Error + ? `Image inspection failed: ${error.message}` + : "Image inspection failed.", + inspected: [], + skipped: selectedAssets.map((asset) => ({ + ref: asset.ref, + reason: "The image inspection request failed.", + })), + } + } +} + +function getUninspectedCitedImageRefs(input: { + readonly manifest: OutputManifest + readonly ledger: ReturnType + readonly inspectedImageRefs: readonly string[] + readonly inspectImagesAvailable: boolean +}): string[] { + if (!input.inspectImagesAvailable) return [] + + const snapshot = input.ledger.snapshot() + const chunksByRef = new Map( + snapshot.chunks.map((chunk) => [chunk.ref, chunk] as const), + ) + const assetsByRef = new Map( + snapshot.assets.map((asset) => [asset.ref, asset] as const), + ) + const inspected = new Set(input.inspectedImageRefs) + const inspectedKeys = new Set( + input.inspectedImageRefs.map((ref) => { + const asset = assetsByRef.get(ref) + return asset ? getCanonicalImageAssetKey(asset, chunksByRef) : ref + }), + ) + + const refs: string[] = [] + const seenKeys = new Set() + const addRef = (ref: string | null): void => { + if (!ref || inspected.has(ref)) return + const asset = assetsByRef.get(ref) + const key = asset ? getCanonicalImageAssetKey(asset, chunksByRef) : ref + if (seenKeys.has(key) || inspectedKeys.has(key)) return + seenKeys.add(key) + refs.push(ref) + } + + for (const citation of input.manifest.citations) { + addRef(resolveImageAssetRef(citation.ref, chunksByRef, assetsByRef)) + } + + for (const artifact of input.manifest.artifacts) { + if (artifact.type !== "image" || artifact.display === false) continue + addRef(resolveImageAssetRef(artifact.ref, chunksByRef, assetsByRef)) + } + + return refs +} + +function resolveImageAssetRef( + ref: string, + chunksByRef: ReadonlyMap, + assetsByRef: ReadonlyMap, +): string | null { + const directAsset = assetsByRef.get(ref) + if (directAsset?.type === "image") return directAsset.ref + + const chunk = chunksByRef.get(ref) + if (!chunk) return null + + if (chunk.assetRef) { + const chunkAsset = assetsByRef.get(chunk.assetRef) + if (chunkAsset?.type === "image") return chunkAsset.ref + } + + if (!chunk.chunkId) return null + const sibling = Array.from(chunksByRef.values()).find( + (candidate) => + candidate.ref !== chunk.ref && + candidate.chunkId === chunk.chunkId && + candidate.source.documentId === chunk.source.documentId && + candidate.assetRef !== undefined && + assetsByRef.get(candidate.assetRef)?.type === "image", + ) + return sibling?.assetRef ?? null +} + +function buildFinalizeRequiresInspectionMessage( + inspectRefs: readonly string[], +): string { + return [ + "Cited page/image assets must be inspected before finalize.", + `Call inspectImage with refs: ${inspectRefs.join(" ")}.`, + "Use a question that locates the cited evidence on those pages.", + "Then call finalize again, using the inspection notes in the answer.", + ].join(" ") +} + +type KnowhereToolOperation = + | "search" + | "list_documents" + | "get_document_outline" + | "read_chunks" + | "grep_chunks" + +type KnowhereSearchToolRequest = z.infer +type KnowhereDocumentReferenceRequest = z.infer< + typeof knowhereDocumentReferenceSchema +> +type KnowhereReadChunksToolRequest = z.infer +type KnowhereGrepChunksToolRequest = z.infer +type DocumentReferenceSummary = { + readonly documentId?: string + readonly localDocumentId?: string + readonly hasJobId: boolean + readonly hasRevisionKey: boolean +} + +async function executeKnowhereSearch(input: { + readonly ledger: ReturnType + readonly knowhereTools: KnowhereToolRuntime + readonly request: KnowhereSearchToolRequest +}): Promise { + return executeKnowhereTextTool({ + operation: "search", + execute: async () => { + const beforeSnapshot = input.ledger.snapshot() + const response = await input.knowhereTools.search({ + query: input.request.query, + targetContent: input.request.targetContent, + purpose: input.request.purpose, + topK: input.request.topK, + signalPaths: input.request.signalPaths, + filterMode: input.request.filterMode, + threshold: input.request.threshold, + }) + const snapshot = input.ledger.addRetrievalResponse(response) + return knowhereToolText.formatSearch({ + response, + retrievalCount: snapshot.retrievalCount, + chunks: snapshot.chunks.slice(beforeSnapshot.chunks.length), + assets: snapshot.assets.slice(beforeSnapshot.assets.length), + }) + }, + }) +} + +async function executeKnowhereReadChunks(input: { + readonly ledger: ReturnType + readonly knowhereTools: KnowhereToolRuntime + readonly request: KnowhereReadChunksToolRequest +}): Promise { + return executeKnowhereTextTool({ + operation: "read_chunks", + validate: () => validateDocumentReference(input.request), + execute: async () => { + const beforeSnapshot = input.ledger.snapshot() + const response = await input.knowhereTools.readChunks(input.request) + const snapshot = input.ledger.addReadChunksResponse(response) + return knowhereToolText.formatReadChunks({ + response, + chunks: snapshot.chunks.slice(beforeSnapshot.chunks.length), + assets: snapshot.assets.slice(beforeSnapshot.assets.length), + }) + }, + }) +} + +async function executeKnowhereGrepChunks(input: { + readonly ledger: ReturnType + readonly knowhereTools: KnowhereToolRuntime + readonly request: KnowhereGrepChunksToolRequest +}): Promise { + return executeKnowhereTextTool({ + operation: "grep_chunks", + validate: () => validateDocumentReference(input.request), + execute: async () => { + const beforeSnapshot = input.ledger.snapshot() + const response = await input.knowhereTools.grepChunks(input.request) + const snapshot = input.ledger.addGrepChunksResponse(response) + return knowhereToolText.formatGrepChunks({ + response, + chunks: snapshot.chunks.slice(beforeSnapshot.chunks.length), + assets: snapshot.assets.slice(beforeSnapshot.assets.length), + }) + }, + }) +} + +async function executeKnowhereTextTool(input: { + readonly operation: KnowhereToolOperation + readonly validate?: () => string | null + readonly execute: () => Promise +}): Promise { + const validationError = input.validate?.() + if (validationError) { + return knowhereToolText.formatError({ + operation: input.operation, + message: validationError, + }) + } + + try { + return await input.execute() + } catch (error) { + return knowhereToolText.formatError({ + operation: input.operation, + message: formatUnknownError(error), + }) + } +} + +function validateDocumentReference( + request: KnowhereDocumentReferenceRequest, +): string | null { + if ( + request.documentId || + request.localDocumentId || + request.jobId + ) { + return null + } + + return "A documentId, localDocumentId, or jobId is required." +} + +function getUniqueTrimmedRefs(refs: readonly string[]): string[] { + const normalizedRefs: string[] = [] + for (const ref of refs) { + const normalizedRef = ref.trim() + if (!normalizedRef || normalizedRefs.includes(normalizedRef)) continue + normalizedRefs.push(normalizedRef) + } + return normalizedRefs +} + async function traceToolCall(input: { readonly toolCalls?: HarnessToolCallTrace[] }, call: { @@ -488,6 +1089,7 @@ function recordToolCall( } function getToolTraceOk(output: unknown): boolean { + if (typeof output === "string") return !output.includes('status="error"') if (!isRecord(output)) return true if (typeof output.ok === "boolean") return output.ok if (typeof output.found === "boolean") return output.found @@ -512,9 +1114,9 @@ function summarizeContextPolicy(policy: ContextPolicy): unknown { } } -function summarizeRetrievalRequest(request: { +function summarizeKnowhereSearchRequest(request: { readonly query: string - readonly modalities?: readonly TargetModality[] + readonly targetContent?: KnowhereSearchTargetContent readonly purpose?: string readonly topK?: number readonly signalPaths?: readonly string[] @@ -523,7 +1125,7 @@ function summarizeRetrievalRequest(request: { }): unknown { return { query: request.query, - modalities: request.modalities ?? ["text"], + targetContent: request.targetContent ?? "all", purpose: request.purpose, topK: request.topK, signalPathCount: request.signalPaths?.length ?? 0, @@ -532,27 +1134,91 @@ function summarizeRetrievalRequest(request: { } } -function summarizeRetrieveOutput(output: unknown): unknown { - if (!isRecord(output)) return output +function summarizeDocumentReference( + request: KnowhereDocumentReferenceRequest, +): DocumentReferenceSummary { return { - ok: output.ok, - retrievalCount: output.retrievalCount, - stopReason: output.stopReason, - failureReason: output.failureReason, - chunkCount: Array.isArray(output.chunks) ? output.chunks.length : 0, - assetCount: Array.isArray(output.assets) ? output.assets.length : 0, + documentId: request.documentId, + localDocumentId: request.localDocumentId, + hasJobId: typeof request.jobId === "string", + hasRevisionKey: typeof request.revisionKey === "string", + } +} + +function summarizeReadChunksRequest( + request: KnowhereReadChunksToolRequest, +): unknown { + return { + ...summarizeDocumentReference(request), + page: request.page, + pageSize: request.pageSize, + sectionPath: request.sectionPath, + startChunk: request.startChunk, + endChunk: request.endChunk, + chunkId: request.chunkId, + chunkType: request.chunkType, + } +} + +function summarizeGrepChunksRequest( + request: KnowhereGrepChunksToolRequest, +): unknown { + return { + ...summarizeDocumentReference(request), + patternLength: request.pattern.trim().length, + continuationCursor: request.continuationCursor, + isRegex: request.isRegex, + isCaseSensitive: request.isCaseSensitive, + maxResults: request.maxResults, + chunkType: request.chunkType, + sectionPathPrefix: request.sectionPathPrefix, + contextChars: request.contextChars, + } +} + +function summarizeKnowhereTextOutput(output: unknown): unknown { + if (typeof output !== "string") return output + return { + ok: !output.includes('status="error"'), + textLength: output.length, + chunkCount: countOccurrences(output, " { return typeof value === "object" && value !== null } +function formatUnknownError(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} + export function buildHarnessSystemPrompt(turn: AgentTurnInput): string { return [ "You are the outer Knowhere Agent Harness.", "KNOWHERE is only an evidence provider. Do not infer or control its internal navigation algorithm.", "Your job is to understand intent, decide context use, optionally retrieve evidence, select evidence/artifacts, create source-backed derived tables when useful, and finalize an output manifest.", "", - "Required workflow:", - "1. Call declareIntent first. Capture constraints like a requested image/table count in constraints.desiredCount.", - "2. Call setContextPolicy next, deciding how prior turns should influence this turn.", + "Recommended workflow:", + "1. Call declareIntent when it helps you plan the response. Capture constraints like a requested image/table count in constraints.desiredCount.", + "2. Call setContextPolicy when prior turns may influence this turn.", "3. When the policy needs prior-turn detail (references or corrections), call readPriorTurn for the relevant ids.", - "4. Call retrieve only when evidence is needed. The query must be concise and self-contained.", - "5. Use readEvidence only for chunk refs already in the evidence ledger.", - "6. Call finalize with text, citations, artifacts, and unresolved issues. finalize requires declareIntent and setContextPolicy first.", + "4. Call knowhere_search when relevance search is needed. Use knowhere_list_documents, knowhere_get_document_outline, knowhere_read_chunks, and knowhere_grep_chunks for focused document reads.", + "5. After Knowhere returns image/page asset refs, call inspectImage on the page/image assets you will cite before finalize. This supplies OCR/visual context and provenance boxes.", + "6. Inspect each unique cited page once; retrieval already bounds the available evidence set.", + "7. knowhere_read_chunks returns complete chunk bodies; control size with page/pageSize, sectionPath, startChunk/endChunk, chunkId, and chunkType.", + "8. Call finalize with text, citations, artifacts, and unresolved issues when you are ready to answer.", "", "Context rules:", "- If the current user request is unrelated to prior turns, set carryHistory to none and do not reuse prior topics.", @@ -624,9 +1297,17 @@ export function buildHarnessSystemPrompt(turn: AgentTurnInput): string { "- Final output is the OutputManifest passed to finalize, not freeform tool JSON or trailing text.", "- artifacts with display=true are the exact images/tables shown. Never display every candidate; honor constraints.desiredCount / maxCount.", "- Use type=derived_table only for tables you create from evidence; every derived_table.sourceRefs entry must reference evidence in the ledger.", - "- citations and selected image/table artifact refs may only reference refs returned by retrieve (in the evidence ledger).", + "- Prefer citation and selected image/table artifact refs returned by Knowhere tools in the evidence ledger.", + "- Place [[cite:n]] immediately after the supported claim. n is the 1-based index into the citations array passed to finalize.", + "- Write one marker per index: [[cite:1]] [[cite:3]] [[cite:5]]. Never group indices as [[cite:1, 3, 5]].", + "- Do not write title/pN, [1], Markdown footnotes, or [Source N: ...] in the answer text. Notebook renders chips from [[cite:n]] and citation metadata.", + "- Repeat [[cite:n]] when another claim uses the same page. Do not collapse same-page citations to one row.", + "- Citation label and source metadata are optional. Notebook resolves citation metadata from evidence refs when possible.", + "- If evidence is relevant but you cannot identify a supporting evidence ref, answer with unresolved issues instead of fabricating a ref.", + "- inspectImage observations are inspection notes, not new source refs. Final citations and displayed image artifacts must use the original retrieved image asset refs.", + "- Do not finalize cited page/image assets from chunk text alone when inspectImage is available. Inspect those asset refs first, then write the answer using the inspection notes.", + "- If text evidence identifies a relevant page/image but does not include the exact fact, inspect the returned image asset for OCR/detail before saying the answer is unavailable.", "- If evidence is insufficient, list it in unresolved instead of fabricating facts.", - "- After a validation-feedback message, fix all listed issues and call finalize again.", `Surface: ${turn.surface}`, `Output capabilities: ${JSON.stringify(turn.outputCapabilities)}`, turn.sourceContext ? `Searchable source context:\n${turn.sourceContext}` : "", diff --git a/src/agent-harness/types.ts b/src/agent-harness/types.ts index 1a1cce9..edd9c2f 100644 --- a/src/agent-harness/types.ts +++ b/src/agent-harness/types.ts @@ -1,4 +1,10 @@ import type { + KnowledgeDocumentReference, + KnowledgeGrepParams, + KnowledgeGrepResponse, + KnowledgeOutline, + KnowledgeReadParams, + KnowledgeReadResponse, RetrievalQueryParams, RetrievalQueryResponse, } from "@ontos-ai/knowhere-sdk" @@ -77,32 +83,71 @@ export type AgentTurnInput = { } } -export type HarnessRetrievalRequest = Pick< +export type KnowhereSearchTargetContent = + | "all" + | "text" + | "image" + | "table" + | "text_image" + | "text_table" + +export type KnowhereSearchRequest = Pick< RetrievalQueryParams, "query" | "topK" | "signalPaths" | "filterMode" | "threshold" > & { - readonly modalities: readonly TargetModality[] + readonly targetContent?: KnowhereSearchTargetContent readonly purpose?: string } -export type RetrievalCapability = { - readonly query: ( - input: HarnessRetrievalRequest, +export type KnowhereDocumentSummary = { + readonly documentId?: string + readonly localDocumentId?: string + readonly revisionKey?: string + readonly namespace?: string + readonly sourceFileName: string + readonly title?: string + readonly status?: string + readonly chunkCount?: number + readonly typeCounts?: Readonly> +} + +export type KnowhereListDocumentsResponse = { + readonly documents: readonly KnowhereDocumentSummary[] +} + +export type KnowhereToolRuntime = { + readonly search: ( + input: KnowhereSearchRequest, ) => Promise + readonly listDocuments: () => Promise + readonly getDocumentOutline: ( + input: KnowledgeDocumentReference, + ) => Promise + readonly readChunks: ( + input: KnowledgeReadParams, + ) => Promise + readonly grepChunks: ( + input: KnowledgeGrepParams, + ) => Promise } export type EvidenceChunk = { readonly ref: string - readonly kind: "result" | "referenced_chunk" + readonly kind: "result" | "referenced_chunk" | "read_chunk" | "grep_match" + readonly chunkId?: string readonly content: string readonly contentPreview: string readonly chunkType: string readonly score: number | null + readonly sourceChunkPath?: string | null + readonly filePath?: string | null + readonly metadata?: Readonly> readonly source: { readonly documentId?: string | null readonly sourceFileName?: string | null readonly sectionPath?: string | null } + readonly revisionKey?: string | null readonly assetRef?: string readonly assetUrl?: string } @@ -111,11 +156,65 @@ export type EvidenceAsset = { readonly ref: string readonly chunkRef: string readonly type: "image" | "table" - readonly assetUrl: string + readonly assetUrl?: string + readonly sourcePath?: string + readonly revisionKey?: string | null readonly source: EvidenceChunk["source"] readonly label: string } +export type ImageInspectionAsset = { + readonly ref: string + readonly label: string + readonly assetUrl?: string | null + readonly sourcePath?: string | null + readonly revisionKey?: string | null + readonly source: EvidenceChunk["source"] +} + +export type ImageInspectionSkippedAsset = { + readonly ref: string + readonly reason: string +} + +export type ImageInspectionInspectedAsset = { + readonly ref: string + readonly label: string +} + +/** + * Normalized box relative to image width/height. Origin is top-left. + * Values are clamped to [0, 1] before render. + */ +export type ImageHighlightBox = { + readonly x: number + readonly y: number + readonly w: number + readonly h: number +} + +/** One page/image may contain multiple answer regions; no per-region labels. */ +export type ImageInspectionHighlights = { + readonly ref: string + readonly regions: readonly ImageHighlightBox[] +} + +export type ImageInspectionRequest = { + readonly question: string + readonly assets: readonly ImageInspectionAsset[] +} + +export type ImageInspectionResponse = { + readonly analysis: string + readonly inspected: readonly ImageInspectionInspectedAsset[] + readonly skipped: readonly ImageInspectionSkippedAsset[] + readonly highlights?: readonly ImageInspectionHighlights[] +} + +export type InspectImages = ( + input: ImageInspectionRequest, +) => Promise + export type EvidenceLedgerSnapshot = { readonly retrievalCount: number readonly chunks: readonly EvidenceChunk[] @@ -128,8 +227,8 @@ export type EvidenceLedgerSnapshot = { export type OutputCitation = { readonly ref: string - readonly label: string - readonly source: EvidenceChunk["source"] + readonly label?: string + readonly source?: EvidenceChunk["source"] } export type OutputArtifact = { @@ -175,6 +274,7 @@ export type HarnessTrace = { readonly finalized: boolean readonly priorTurnReads: readonly string[] readonly toolCalls: readonly HarnessToolCallTrace[] + readonly imageHighlights: readonly ImageInspectionHighlights[] readonly validationErrors: readonly string[] readonly revisionsUsed: number } diff --git a/src/agent-harness/validator.test.ts b/src/agent-harness/validator.test.ts deleted file mode 100644 index 2815241..0000000 --- a/src/agent-harness/validator.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { validateOutputManifest } from "./validator" -import type { - ContextPolicy, - EvidenceLedgerSnapshot, - IntentFrame, - OutputManifest, -} from "./types" - -describe("validateOutputManifest", () => { - it("requires finalize to be the successful output path", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ text: "Freeform answer." }), - intent: makeIntent({ groundingPolicy: "no_retrieval" }), - contextPolicy: unrelatedContextPolicy, - finalized: false, - ledger: emptyLedger, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Agent must call finalize to produce the output manifest.", - ) - }) - - it("requires the agent to declare intent and context policy before finalizing", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ text: "Answer." }), - ledger: emptyLedger, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Agent must declare intent before finalizing.", - ) - expect(validation.errors).toContain( - "Agent must set context policy before finalizing.", - ) - }) - - it("limits displayed artifacts using the declared intent instead of hard-coded media rules", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ - artifacts: [ - { - type: "image", - ref: "asset:r1:result:1", - display: true, - reason: "front", - }, - { - type: "image", - ref: "asset:r1:result:2", - display: true, - reason: "back", - }, - { - type: "image", - ref: "asset:r1:result:3", - display: true, - reason: "extra candidate", - }, - ], - }), - intent: makeIntent({ desiredCount: 2, maxCount: 2 }), - contextPolicy: unrelatedContextPolicy, - ledger: { - ...emptyLedger, - assets: [ - makeAsset("asset:r1:result:1"), - makeAsset("asset:r1:result:2"), - makeAsset("asset:r1:result:3"), - ], - }, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Displayed artifact count 3 exceeds desired count 2.", - ) - expect(validation.errors).toContain( - "Displayed artifact count 3 exceeds maximum count 2.", - ) - }) - - it("rejects grounded answers that use evidence without citations or selected artifacts", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ text: "Revenue increased." }), - intent: makeIntent({}), - contextPolicy: unrelatedContextPolicy, - ledger: { - ...emptyLedger, - chunks: [ - { - ref: "r1:result:1", - kind: "result", - content: "Revenue increased.", - contentPreview: "Revenue increased.", - chunkType: "text", - score: 0.9, - source: { - documentId: "doc_1", - sourceFileName: "report.pdf", - sectionPath: "Q4", - }, - }, - ], - }, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Grounded output used evidence but did not cite or display any selected evidence.", - ) - }) - - it("accepts source-backed derived tables and rejects missing source refs", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ - artifacts: [ - { - type: "derived_table", - ref: "derived:table:1", - title: "Revenue comparison", - columns: ["Metric", "Value"], - rows: [["Revenue", "$10M"]], - sourceRefs: ["r1:result:1"], - display: true, - reason: "Structured comparison requested by the user.", - }, - { - type: "derived_table", - ref: "derived:table:2", - title: "Invalid table", - columns: ["Metric", "Value"], - rows: [["Revenue"]], - sourceRefs: ["missing"], - display: true, - reason: "Demonstrates validation.", - }, - ], - }), - intent: makeIntent({}), - contextPolicy: unrelatedContextPolicy, - ledger: { - ...emptyLedger, - chunks: [ - { - ref: "r1:result:1", - kind: "result", - content: "Revenue was $10M.", - contentPreview: "Revenue was $10M.", - chunkType: "text", - score: 0.9, - source: { - documentId: "doc_1", - sourceFileName: "report.pdf", - sectionPath: "Revenue", - }, - }, - ], - }, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Derived table source ref 'missing' was not found in the evidence ledger.", - ) - expect(validation.errors).toContain( - "Derived table row 1 has 1 cells but expected 2.", - ) - }) - - it("requires compare outputs to cite at least two evidence refs", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ - text: "A is stronger than B.", - citations: [ - { - ref: "r1:result:1", - label: "report.pdf / A", - source: { - documentId: "doc_1", - sourceFileName: "report.pdf", - sectionPath: "A", - }, - }, - ], - }), - intent: { - ...makeIntent({}), - task: "compare", - }, - contextPolicy: unrelatedContextPolicy, - ledger: { - ...emptyLedger, - chunks: [ - { - ref: "r1:result:1", - kind: "result", - content: "A is strong.", - contentPreview: "A is strong.", - chunkType: "text", - score: 0.9, - source: { - documentId: "doc_1", - sourceFileName: "report.pdf", - sectionPath: "A", - }, - }, - ], - }, - surface: "notebook_chat", - }) - - expect(validation.errors).toContain( - "Compare outputs that must use sources require at least two evidence refs or an explicit unresolved reason.", - ) - }) - - it("keeps typing compose output insertion-ready", () => { - const validation = validateOutputManifest({ - manifest: makeManifest({ text: "- bullet\n- list" }), - intent: makeIntent({ groundingPolicy: "no_retrieval" }), - contextPolicy: unrelatedContextPolicy, - ledger: emptyLedger, - surface: "typing_compose", - }) - - expect(validation.errors).toContain( - "Typing compose output must be insertion-ready plain text.", - ) - }) -}) - -const emptyLedger: EvidenceLedgerSnapshot = { - retrievalCount: 0, - chunks: [], - assets: [], - evidenceText: [], - stopReasons: [], - failureReasons: [], - decisionTraces: [], -} - -const unrelatedContextPolicy: ContextPolicy = { - carryHistory: "none", - reason: "The current turn is self-contained.", - activePriorTurnIds: [], -} - -function makeIntent( - constraints: IntentFrame["constraints"] & { - readonly groundingPolicy?: IntentFrame["groundingPolicy"] - }, -): IntentFrame { - return { - task: "answer", - dependsOnPreviousTurn: false, - retrievalNeeded: constraints.groundingPolicy === "no_retrieval" ? "no" : "yes", - targetModalities: ["text"], - constraints, - groundingPolicy: constraints.groundingPolicy ?? "must_use_sources", - } -} - -function makeManifest(overrides: Partial): OutputManifest { - return { - text: "", - citations: [], - artifacts: [], - unresolved: [], - ...overrides, - } -} - -function makeAsset(ref: string): EvidenceLedgerSnapshot["assets"][number] { - return { - ref, - chunkRef: ref.replace("asset:", ""), - type: "image", - assetUrl: `https://assets.example/${ref}.png`, - label: ref, - source: { - documentId: "doc_1", - sourceFileName: "report.pdf", - sectionPath: ref, - }, - } -} diff --git a/src/agent-harness/validator.ts b/src/agent-harness/validator.ts deleted file mode 100644 index 89b5e43..0000000 --- a/src/agent-harness/validator.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { - ContextPolicy, - EvidenceLedgerSnapshot, - IntentFrame, - OutputManifest, -} from "./types" - -export type ManifestValidationInput = { - readonly manifest: OutputManifest - readonly intent?: IntentFrame - readonly contextPolicy?: ContextPolicy - readonly finalized?: boolean - readonly ledger: EvidenceLedgerSnapshot - readonly surface: "notebook_chat" | "typing_compose" | "typing_quick_ask" -} - -export type ManifestValidationResult = { - readonly ok: boolean - readonly errors: readonly string[] -} - -export function validateOutputManifest( - input: ManifestValidationInput, -): ManifestValidationResult { - const errors: string[] = [] - const text = input.manifest.text.trim() - - if (!text && input.manifest.artifacts.every((artifact) => !artifact.display)) { - errors.push("Final output must contain text or at least one displayed artifact.") - } - - validateWorkflow(input, errors) - validateArtifactRefs(input, errors) - validateArtifactCounts(input, errors) - validateGrounding(input, errors) - validateTaskEvidence(input, errors) - validateTypingText(input, errors) - - return { - ok: errors.length === 0, - errors, - } -} - -function validateWorkflow( - input: ManifestValidationInput, - errors: string[], -): void { - if (input.finalized === false) { - errors.push("Agent must call finalize to produce the output manifest.") - } - - if (!input.intent) { - errors.push("Agent must declare intent before finalizing.") - } - - if (!input.contextPolicy) { - errors.push("Agent must set context policy before finalizing.") - } -} - -function validateArtifactRefs( - input: ManifestValidationInput, - errors: string[], -): void { - const knownRefs = new Set([ - ...input.ledger.chunks.map((chunk) => chunk.ref), - ...input.ledger.assets.map((asset) => asset.ref), - ]) - - for (const artifact of input.manifest.artifacts) { - if (artifact.type === "derived_table") { - artifact.rows.forEach((row, index) => { - if (row.length !== artifact.columns.length) { - errors.push( - `Derived table row ${index + 1} has ${row.length} cells but expected ${artifact.columns.length}.`, - ) - } - }) - - for (const ref of artifact.sourceRefs) { - if (!knownRefs.has(ref)) { - errors.push( - `Derived table source ref '${ref}' was not found in the evidence ledger.`, - ) - } - } - continue - } - - if (!knownRefs.has(artifact.ref)) { - errors.push( - `Artifact ref '${artifact.ref}' was not found in the evidence ledger.`, - ) - } - } - - for (const citation of input.manifest.citations) { - if (!knownRefs.has(citation.ref)) { - errors.push(`Citation ref '${citation.ref}' was not found in the evidence ledger.`) - } - } -} - -function validateArtifactCounts( - input: ManifestValidationInput, - errors: string[], -): void { - const displayedCount = input.manifest.artifacts.filter( - (artifact) => artifact.display, - ).length - const desiredCount = input.intent?.constraints.desiredCount - const maxCount = input.intent?.constraints.maxCount - - if (typeof desiredCount === "number" && displayedCount > desiredCount) { - errors.push( - `Displayed artifact count ${displayedCount} exceeds desired count ${desiredCount}.`, - ) - } - - if (typeof maxCount === "number" && displayedCount > maxCount) { - errors.push( - `Displayed artifact count ${displayedCount} exceeds maximum count ${maxCount}.`, - ) - } -} - -function validateGrounding( - input: ManifestValidationInput, - errors: string[], -): void { - if (input.intent?.groundingPolicy !== "must_use_sources") return - - const hasLedgerEvidence = - input.ledger.chunks.length > 0 || input.ledger.evidenceText.length > 0 - const hasOutputEvidence = - input.manifest.citations.length > 0 || - input.manifest.artifacts.some((artifact) => artifact.display) - const hasUnresolved = input.manifest.unresolved.length > 0 - - if (!hasLedgerEvidence && !hasUnresolved) { - errors.push( - "Grounded output requires evidence or an explicit unresolved reason.", - ) - } - - if (hasLedgerEvidence && !hasOutputEvidence && !hasUnresolved) { - errors.push( - "Grounded output used evidence but did not cite or display any selected evidence.", - ) - } -} - -function validateTaskEvidence( - input: ManifestValidationInput, - errors: string[], -): void { - if (input.intent?.groundingPolicy !== "must_use_sources") return - if (input.manifest.unresolved.length > 0) return - - const refs = getOutputEvidenceRefs(input.manifest) - - if (input.intent.task === "compare" && refs.size < 2) { - errors.push( - "Compare outputs that must use sources require at least two evidence refs or an explicit unresolved reason.", - ) - } - - if (input.intent.task === "summarize" && refs.size < 1) { - errors.push( - "Summaries that must use sources require at least one evidence ref or an explicit unresolved reason.", - ) - } -} - -function getOutputEvidenceRefs(manifest: OutputManifest): Set { - const refs = new Set() - - for (const citation of manifest.citations) refs.add(citation.ref) - - for (const artifact of manifest.artifacts) { - if (!artifact.display) continue - if (artifact.type === "derived_table") { - artifact.sourceRefs.forEach((ref) => refs.add(ref)) - } else { - refs.add(artifact.ref) - } - } - - return refs -} - -function validateTypingText( - input: ManifestValidationInput, - errors: string[], -): void { - if (input.surface !== "typing_compose") return - - const text = input.manifest.text - if (/```|^\s*#{1,6}\s|^\s*[-*]\s/m.test(text)) { - errors.push("Typing compose output must be insertion-ready plain text.") - } -} diff --git a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.test.ts b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.test.ts new file mode 100644 index 0000000..aa1d09b --- /dev/null +++ b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + fetch: vi.fn(), +})) + +vi.mock("@/integrations/knowhere-demo", () => ({ + knowhereDemoApi: { + resolveApiURL: (pathname: string) => `https://demo.example${pathname}`, + }, +})) + +import { GET } from "./route" + +describe("GET /api/demo-sources/[demoSourceId]/assets/[...assetPath]", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal("fetch", mocks.fetch) + }) + + it("serves demo image assets inline", async () => { + mocks.fetch.mockResolvedValue( + new Response("image bytes", { + status: 200, + headers: { + "content-type": "image/png", + }, + }), + ) + + const response = await GET( + new Request( + "http://localhost:3001/api/demo-sources/demo_1/assets/images/page-1.png", + ), + { + params: Promise.resolve({ + demoSourceId: "demo_1", + assetPath: ["images", "page-1.png"], + }), + }, + ) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toBe("image/png") + expect(response.headers.get("content-disposition")).toBe("inline") + expect(mocks.fetch).toHaveBeenCalledWith( + "https://demo.example/api/v1/demo/sources/demo_1/assets/images/page-1.png", + { cache: "no-store" }, + ) + }) +}) diff --git a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts index 247f645..7dc89c3 100644 --- a/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts +++ b/src/app/api/demo-sources/[demoSourceId]/assets/[...assetPath]/route.ts @@ -33,6 +33,7 @@ export async function GET( status: 200, headers: { "content-type": response.headers.get("content-type") ?? "application/octet-stream", + "content-disposition": "inline", "cache-control": "public, max-age=3600", }, }) diff --git a/src/app/api/demo-sources/materialize/route.test.ts b/src/app/api/demo-sources/materialize/route.test.ts index 9b41725..0818dca 100644 --- a/src/app/api/demo-sources/materialize/route.test.ts +++ b/src/app/api/demo-sources/materialize/route.test.ts @@ -232,6 +232,7 @@ function makeSource( sizeBytes: 5648867, status: "ready", failureReason: null, + failureStage: null, knowhereJobId: null, knowhereDocumentId: "doc_user_copy", stagedBlobPathname: null, diff --git a/src/app/api/parsed-assets/inline/route.test.ts b/src/app/api/parsed-assets/inline/route.test.ts new file mode 100644 index 0000000..1bff9d3 --- /dev/null +++ b/src/app/api/parsed-assets/inline/route.test.ts @@ -0,0 +1,96 @@ +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + getAuthenticated: vi.fn(), + getBlob: vi.fn(), +})) + +vi.mock("@/domains/workspace/request-context", () => ({ + notebookRequestContext: { + getAuthenticated: mocks.getAuthenticated, + }, +})) + +vi.mock("@vercel/blob", () => ({ + BlobNotFoundError: class BlobNotFoundError extends Error {}, + get: mocks.getBlob, +})) + +import { GET } from "./route" + +describe("GET /api/parsed-assets/inline", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAuthenticated.mockResolvedValue({ + workspace: { id: "workspace_1" }, + }) + }) + + it("serves Notebook parsed document image blobs inline", async () => { + const assetUrl = + "https://store.public.blob.vercel-storage.com/workspaces/workspace_1/parsed-documents/doc_1/rev_1/page_citation_assets/page-1.png" + mocks.getBlob.mockResolvedValue({ + statusCode: 200, + stream: new Response("image bytes").body, + blob: { + contentType: "image/png", + }, + }) + + const response = await GET( + new NextRequest( + `http://localhost:3001/api/parsed-assets/inline?url=${encodeURIComponent( + assetUrl, + )}`, + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toBe("image/png") + expect(response.headers.get("content-disposition")).toBe("inline") + await expect(response.text()).resolves.toBe("image bytes") + expect(mocks.getBlob).toHaveBeenCalledWith( + "workspaces/workspace_1/parsed-documents/doc_1/rev_1/page_citation_assets/page-1.png", + { access: "public" }, + ) + }) + + it("rejects blob URLs outside the authenticated workspace", async () => { + const assetUrl = + "https://store.public.blob.vercel-storage.com/workspaces/workspace_2/parsed-documents/doc_1/rev_1/page_citation_assets/page-1.png" + + const response = await GET( + new NextRequest( + `http://localhost:3001/api/parsed-assets/inline?url=${encodeURIComponent( + assetUrl, + )}`, + ), + ) + + expect(response.status).toBe(404) + expect(mocks.getBlob).not.toHaveBeenCalled() + }) + + it("does not serve non-image parsed assets through the inline image route", async () => { + const assetUrl = + "https://store.public.blob.vercel-storage.com/workspaces/workspace_1/sources/source_1/parsed-result/tables/table-1.html" + mocks.getBlob.mockResolvedValue({ + statusCode: 200, + stream: new Response("
").body, + blob: { + contentType: "text/html; charset=utf-8", + }, + }) + + const response = await GET( + new NextRequest( + `http://localhost:3001/api/parsed-assets/inline?url=${encodeURIComponent( + assetUrl, + )}`, + ), + ) + + expect(response.status).toBe(415) + }) +}) diff --git a/src/app/api/parsed-assets/inline/route.ts b/src/app/api/parsed-assets/inline/route.ts new file mode 100644 index 0000000..9e9beb3 --- /dev/null +++ b/src/app/api/parsed-assets/inline/route.ts @@ -0,0 +1,159 @@ +import { BlobNotFoundError, get } from "@vercel/blob" +import type { NextRequest } from "next/server" + +import { notebookRequestContext } from "@/domains/workspace/request-context" + +type ParsedAssetBlobPath = { + readonly pathname: string + readonly assetPath: string +} + +const notebookBlobHostSuffix = ".blob.vercel-storage.com" +const inlineImageContentTypes = new Set([ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +]) +const parsedDocumentsPathPattern = + /^\/?workspaces\/([^/]+)\/parsed-documents\/[^/]+\/[^/]+\/(.+)$/u +const parsedResultPathPattern = + /^\/?workspaces\/([^/]+)\/sources\/[^/]+\/parsed-result\/(.+)$/u + +export async function GET(request: NextRequest): Promise { + const { workspace } = await notebookRequestContext.getAuthenticated() + const parsedAssetBlobPath = getParsedAssetBlobPath( + request.nextUrl.searchParams.get("url"), + workspace.id, + ) + if (!parsedAssetBlobPath) { + return Response.json({ message: "Parsed asset not found." }, { status: 404 }) + } + + try { + const blob = await get(parsedAssetBlobPath.pathname, { access: "public" }) + if (!blob) { + return Response.json( + { message: "Parsed asset not found." }, + { status: 404 }, + ) + } + if (blob.statusCode !== 200) { + return new Response(null, { status: 304 }) + } + + const contentType = getInlineImageContentType( + blob.blob.contentType, + parsedAssetBlobPath.assetPath, + ) + if (!contentType) { + return Response.json( + { message: "Parsed asset is not an image." }, + { status: 415 }, + ) + } + + return new Response(blob.stream, { + status: 200, + headers: { + "content-type": contentType, + "content-disposition": "inline", + "cache-control": "public, max-age=3600", + }, + }) + } catch (error) { + if (error instanceof BlobNotFoundError) { + return Response.json( + { message: "Parsed asset not found." }, + { status: 404 }, + ) + } + throw error + } +} + +function getParsedAssetBlobPath( + assetUrl: string | null, + workspaceId: string, +): ParsedAssetBlobPath | null { + if (!assetUrl) return null + + const url = parseAbsoluteUrl(assetUrl) + if (!url) return null + if (url.protocol !== "https:") return null + if (!url.hostname.toLowerCase().endsWith(notebookBlobHostSuffix)) return null + + const pathname = decodePathname(url.pathname) + if (!pathname) return null + + const documentsMatch = parsedDocumentsPathPattern.exec(pathname) + if (documentsMatch) { + return getWorkspaceScopedAssetPath(documentsMatch, workspaceId, pathname) + } + + const parsedResultMatch = parsedResultPathPattern.exec(pathname) + if (parsedResultMatch) { + return getWorkspaceScopedAssetPath(parsedResultMatch, workspaceId, pathname) + } + + return null +} + +function getWorkspaceScopedAssetPath( + match: RegExpExecArray, + workspaceId: string, + pathname: string, +): ParsedAssetBlobPath | null { + const matchedWorkspaceId = match[1] + const assetPath = match[2] + if (matchedWorkspaceId !== workspaceId || !assetPath) return null + + return { + pathname: pathname.replace(/^\/+/u, ""), + assetPath, + } +} + +function getInlineImageContentType( + contentType: string | null, + assetPath: string, +): string | null { + const normalizedContentType = getBaseContentType(contentType) + if (normalizedContentType && inlineImageContentTypes.has(normalizedContentType)) { + return contentType + } + + return inferImageContentType(assetPath) +} + +function getBaseContentType(contentType: string | null): string | null { + const baseContentType = contentType?.split(";")[0]?.trim().toLowerCase() + return baseContentType && baseContentType.length > 0 ? baseContentType : null +} + +function inferImageContentType(assetPath: string): string | null { + const pathname = assetPath.toLowerCase().split("?")[0] ?? assetPath + if (pathname.endsWith(".png")) return "image/png" + if (pathname.endsWith(".jpg") || pathname.endsWith(".jpeg")) { + return "image/jpeg" + } + if (pathname.endsWith(".gif")) return "image/gif" + if (pathname.endsWith(".webp")) return "image/webp" + return null +} + +function parseAbsoluteUrl(value: string): URL | null { + try { + return new URL(value) + } catch { + return null + } +} + +function decodePathname(pathname: string): string | null { + try { + return decodeURIComponent(pathname) + } catch { + return null + } +} diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts index 524a657..f410285 100644 --- a/src/app/api/sources/[sourceId]/chunks/route.test.ts +++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts @@ -2,19 +2,18 @@ import { NextRequest } from "next/server" import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ - blobGet: vi.fn(), - blobPut: vi.fn(), deleteBlob: vi.fn(), ensureApiKeyForWorkspace: vi.fn(), ensureWorkspace: vi.fn(), fetchDemoChunkPage: vi.fn(), findSourceInWorkspace: vi.fn(), getCurrentUser: vi.fn(), - getSourceParseAssetUrls: vi.fn(), + listChunks: vi.fn(), localizeRemoteDocument: vi.fn(), makeKnowhereClient: vi.fn(), + makeKnowhereClientWithParsedStorage: vi.fn(), + readChunks: vi.fn(), requireUser: vi.fn(), - updateSourceRevisionKey: vi.fn(), })) vi.mock("next/headers", () => ({ @@ -39,20 +38,18 @@ vi.mock("@/infrastructure/auth", () => ({ vi.mock("@/integrations/knowhere", () => ({ makeKnowhereClient: mocks.makeKnowhereClient, + makeKnowhereClientWithParsedStorage: + mocks.makeKnowhereClientWithParsedStorage, })) vi.mock("@vercel/blob", () => ({ del: mocks.deleteBlob, - get: mocks.blobGet, - put: mocks.blobPut, })) vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, - getParseAssetUrls: mocks.getSourceParseAssetUrls, localizeRemoteDocument: mocks.localizeRemoteDocument, - updateSourceRevisionKey: mocks.updateSourceRevisionKey, }, })) @@ -67,11 +64,10 @@ import { GET } from "./route" describe("GET /api/sources/[sourceId]/chunks", () => { beforeEach(() => { vi.clearAllMocks() - mocks.blobGet.mockResolvedValue(null) - mocks.blobPut.mockImplementation(async (pathname: string) => ({ - url: `https://blob.example/${pathname}`, - })) - mocks.updateSourceRevisionKey.mockResolvedValue(null) + mocks.makeKnowhereClientWithParsedStorage.mockReturnValue({ + client: { documents: { listChunks: mocks.listChunks } }, + knowledge: { readChunks: mocks.readChunks }, + }) }) it("serves API-owned demo chunks for anonymous canonical demo sources", async () => { @@ -132,7 +128,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }) expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() }) it("loads every API-owned demo chunk page for full anonymous chunk requests", async () => { @@ -287,7 +282,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled() expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() }) it("serves demo chunks for authenticated materialized demo sources", async () => { @@ -377,7 +371,6 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }) expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled() expect(mocks.makeKnowhereClient).not.toHaveBeenCalled() - expect(mocks.getSourceParseAssetUrls).not.toHaveBeenCalled() }) it("logs the demo chunk load failure before returning 404", async () => { @@ -429,32 +422,7 @@ describe("GET /api/sources/[sourceId]/chunks", () => { } }) - it("loads authenticated workspace chunks without probing the demo endpoint first", async () => { - const knowhereClient = { - documents: { - listChunks: vi.fn(async () => ({ - chunks: [ - { - id: "dchk_1", - chunkId: "parser_1", - chunkType: "text", - content: "Workspace chunk", - sectionPath: "Summary", - sourceChunkPath: "Default_Root/notes.pdf/Summary", - filePath: null, - metadata: {}, - sortOrder: 0, - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 1, - totalPages: 1, - }, - })), - }, - } + it("loads authenticated workspace chunks from parsed storage without durable hardening", async () => { mocks.getCurrentUser.mockResolvedValue({ id: "user_1", email: null, @@ -466,42 +434,162 @@ describe("GET /api/sources/[sourceId]/chunks", () => { namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), }) - mocks.findSourceInWorkspace.mockResolvedValue({ - id: "00000000-0000-0000-0000-000000000002", - workspaceId: "workspace_1", - title: "notes.pdf", - mimeType: "application/pdf", - sizeBytes: 1024, - status: "ready", - failureReason: null, - knowhereJobId: "job_1", - knowhereDocumentId: "doc_1", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: null, - demoKey: null, + mocks.findSourceInWorkspace.mockResolvedValue( + makeReadySource({ + id: "00000000-0000-0000-0000-000000000002", + knowhereJobId: "job_1", + knowhereDocumentId: "doc_1", + }), + ) + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") + mocks.readChunks.mockResolvedValue({ + document: { + localDocumentId: "doc_1", + resultDirectoryPath: "parsed-storage:doc_1", + }, + chunks: [ + { + position: 1, + chunkId: "parser_1", + chunkType: "image", + content: "Workspace chunk", + readableContent: "Workspace chunk", + sectionPath: "Summary", + sourceChunkPath: "Summary", + filePath: "images/chart.png", + assetUrl: + "https://fake.public.blob.vercel-storage.com/images/chart.png", + metadata: {}, + }, + ], + page: 1, + pageSize: 1, + totalChunks: 1, + totalPages: 1, + }) + + const response = await GET( + new NextRequest( + "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000002/chunks?page=1&pageSize=1", + ), + { + params: Promise.resolve({ + sourceId: "00000000-0000-0000-0000-000000000002", + }), + }, + ) + + await expect(response.json()).resolves.toMatchObject({ + chunks: [ + { + parserChunkId: "parser_1", + documentId: "doc_1", + assetUrl: + "https://fake.public.blob.vercel-storage.com/images/chart.png", + sourceTitle: "notes.pdf", + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + }, + }) + expect(response.status).toBe(200) + expect(mocks.fetchDemoChunkPage).not.toHaveBeenCalled() + expect(mocks.readChunks).toHaveBeenCalledWith({ + documentId: "doc_1", + revisionKey: "job_1", + page: 1, + pageSize: 1, + }) + expect(mocks.listChunks).not.toHaveBeenCalled() + }) + + it("falls back to raw Knowhere asset URLs for workspace chunk display when storage misses", async () => { + mocks.getCurrentUser.mockResolvedValue({ + id: "user_1", + email: null, + name: null, + }) + mocks.ensureWorkspace.mockResolvedValue({ + id: "workspace_1", + userId: "user_1", + namespace: "notebook-workspace_1", createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, }) + mocks.findSourceInWorkspace.mockResolvedValue( + makeReadySource({ + id: "00000000-0000-0000-0000-000000000002", + knowhereJobId: "job_1", + knowhereDocumentId: "doc_1", + }), + ) mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") - mocks.makeKnowhereClient.mockReturnValue(knowhereClient) - mocks.getSourceParseAssetUrls.mockResolvedValue({}) + mocks.readChunks.mockResolvedValue({ + document: { + localDocumentId: "doc_1", + resultDirectoryPath: "remote:doc_1", + }, + chunks: [ + { + position: 1, + chunkId: "parser_1", + chunkType: "image", + content: "Workspace chunk", + readableContent: "Workspace chunk", + sectionPath: "Summary", + sourceChunkPath: "Summary", + filePath: "images/chart.png", + metadata: {}, + }, + ], + page: 1, + pageSize: 1, + totalChunks: 1, + totalPages: 1, + }) + mocks.listChunks.mockResolvedValue({ + chunks: [ + { + id: "document_chunk_1", + chunkId: "parser_1", + chunkType: "image", + content: "Workspace chunk", + sectionId: null, + sectionPath: "Summary", + sourceChunkPath: "Summary", + filePath: "images/chart.png", + sortOrder: 0, + metadata: {}, + assetUrl: "https://knowhere.example/assets/chart.png", + }, + ], + pagination: { + page: 1, + pageSize: 1, + total: 1, + totalPages: 1, + }, + }) const response = await GET( new NextRequest( "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000002/chunks?page=1&pageSize=1", ), - { params: Promise.resolve({ sourceId: "00000000-0000-0000-0000-000000000002" }) }, + { + params: Promise.resolve({ + sourceId: "00000000-0000-0000-0000-000000000002", + }), + }, ) await expect(response.json()).resolves.toMatchObject({ chunks: [ { - chunkId: "dchk_1", parserChunkId: "parser_1", documentId: "doc_1", + assetUrl: "https://knowhere.example/assets/chart.png", sourceTitle: "notes.pdf", }, ], @@ -512,15 +600,116 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }, }) expect(response.status).toBe(200) - expect(mocks.fetchDemoChunkPage).not.toHaveBeenCalled() - expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith("doc_1", { + expect(mocks.readChunks).toHaveBeenCalledWith({ + documentId: "doc_1", + revisionKey: "job_1", + page: 1, + pageSize: 1, + }) + expect(mocks.listChunks).toHaveBeenCalledWith("doc_1", { page: 1, pageSize: 1, includeAssetUrls: true, }) }) - it("materializes a remote source id on open before loading chunks", async () => { + it("returns processing when a workspace source is not ready", async () => { + mocks.getCurrentUser.mockResolvedValue({ + id: "user_1", + email: null, + name: null, + }) + mocks.ensureWorkspace.mockResolvedValue({ + id: "workspace_1", + userId: "user_1", + namespace: "notebook-workspace_1", + createdAt: new Date("2026-05-10T00:00:00.000Z"), + }) + mocks.findSourceInWorkspace.mockResolvedValue( + makeReadySource({ + id: "00000000-0000-0000-0000-000000000002", + status: "parsing", + knowhereJobId: "job_1", + knowhereDocumentId: "doc_1", + }), + ) + + const response = await GET( + new NextRequest( + "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000002/chunks?page=1&pageSize=1", + ), + { + params: Promise.resolve({ + sourceId: "00000000-0000-0000-0000-000000000002", + }), + }, + ) + + await expect(response.json()).resolves.toEqual({ + chunks: [], + pagination: { + page: 1, + pageSize: 1, + total: 0, + totalPages: 0, + }, + message: "Source is still being prepared.", + }) + expect(response.status).toBe(202) + expect(mocks.readChunks).not.toHaveBeenCalled() + }) + + it("marks workspace chunks unavailable when the parsed document is missing remotely", async () => { + const notFoundError = new Error("Document not found") + notFoundError.name = "NotFoundError" + mocks.getCurrentUser.mockResolvedValue({ + id: "user_1", + email: null, + name: null, + }) + mocks.ensureWorkspace.mockResolvedValue({ + id: "workspace_1", + userId: "user_1", + namespace: "notebook-workspace_1", + createdAt: new Date("2026-05-10T00:00:00.000Z"), + }) + mocks.findSourceInWorkspace.mockResolvedValue( + makeReadySource({ + id: "00000000-0000-0000-0000-000000000002", + knowhereJobId: "job_1", + knowhereDocumentId: "doc_missing", + }), + ) + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") + mocks.readChunks.mockRejectedValue(notFoundError) + + const response = await GET( + new NextRequest( + "http://localhost:3001/api/sources/00000000-0000-0000-0000-000000000002/chunks?page=1&pageSize=1", + ), + { + params: Promise.resolve({ + sourceId: "00000000-0000-0000-0000-000000000002", + }), + }, + ) + + await expect(response.json()).resolves.toEqual({ + chunks: [], + pagination: { + page: 1, + pageSize: 1, + total: 0, + totalPages: 0, + }, + message: + "Source is unavailable. The parsed document is not available locally and could not be loaded from Knowhere.", + isUnavailable: true, + }) + expect(response.status).toBe(200) + }) + + it("materializes a remote source id and reads chunks through the SDK", async () => { const knowhereClient = { documents: { list: vi.fn(async () => ({ @@ -537,29 +726,7 @@ describe("GET /api/sources/[sourceId]/chunks", () => { }, ], })), - listChunks: vi.fn(async () => ({ - documentId: "doc_remote", - jobResultId: "job_result_1", - chunks: [ - { - id: "dchk_remote", - chunkId: "parser_remote", - chunkType: "text", - content: "Remote chunk", - sectionPath: "Summary", - sourceChunkPath: "Default_Root/remote.pdf/Summary", - filePath: null, - metadata: {}, - sortOrder: 0, - }, - ], - pagination: { - page: 1, - pageSize: 1, - total: 1, - totalPages: 1, - }, - })), + listChunks: vi.fn(), }, } mocks.getCurrentUser.mockResolvedValue({ @@ -576,24 +743,38 @@ describe("GET /api/sources/[sourceId]/chunks", () => { mocks.fetchDemoChunkPage.mockRejectedValue(new Error("not a demo")) mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123") mocks.makeKnowhereClient.mockReturnValue(knowhereClient) - mocks.localizeRemoteDocument.mockResolvedValue({ - id: "00000000-0000-0000-0000-000000000009", - workspaceId: "workspace_1", - title: "remote.pdf", - mimeType: "application/pdf", - sizeBytes: 0, - status: "ready", - failureReason: null, - knowhereJobId: "job_result_1", - knowhereDocumentId: "doc_remote", - stagedBlobPathname: null, - stagedBlobUrl: null, - originalBlobPathname: null, - originalBlobUrl: null, - demoKey: null, - createdAt: new Date("2026-05-10T00:00:00.000Z"), - updatedAt: new Date("2026-05-10T00:00:00.000Z"), - deletedAt: null, + mocks.localizeRemoteDocument.mockResolvedValue( + makeReadySource({ + id: "00000000-0000-0000-0000-000000000009", + title: "remote.pdf", + knowhereJobId: "job_result_1", + knowhereDocumentId: "doc_remote", + }), + ) + mocks.readChunks.mockResolvedValue({ + document: { + localDocumentId: "doc_remote", + resultDirectoryPath: "parsed-storage:doc_remote", + }, + chunks: [ + { + position: 1, + chunkId: "parser_remote", + chunkType: "page", + content: "Remote chunk", + readableContent: "Remote chunk", + sectionPath: "Summary", + sourceChunkPath: "Summary", + filePath: "pages/page-1.png", + assetUrl: + "https://fake.public.blob.vercel-storage.com/pages/page-1.png", + metadata: {}, + }, + ], + page: 1, + pageSize: 1, + totalChunks: 1, + totalPages: 1, }) const response = await GET( @@ -610,9 +791,10 @@ describe("GET /api/sources/[sourceId]/chunks", () => { await expect(response.json()).resolves.toMatchObject({ chunks: [ { - chunkId: "dchk_remote", parserChunkId: "parser_remote", documentId: "doc_remote", + assetUrl: + "https://fake.public.blob.vercel-storage.com/pages/page-1.png", sourceTitle: "remote.pdf", }, ], @@ -628,25 +810,44 @@ describe("GET /api/sources/[sourceId]/chunks", () => { "workspace_1", "session=abc", ) - expect(mocks.localizeRemoteDocument).toHaveBeenCalledWith( - "workspace_1", - { - documentId: "doc_remote", - namespace: "default", - status: "ready", - title: "remote.pdf", - mimeType: "application/pdf", - sizeBytes: undefined, - revisionKey: "job_result_1", - }, - ) - expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith( - "doc_remote", - { - page: 1, - pageSize: 1, - includeAssetUrls: true, - }, - ) + expect(mocks.localizeRemoteDocument).toHaveBeenCalledWith("workspace_1", { + documentId: "doc_remote", + namespace: "default", + status: "ready", + title: "remote.pdf", + mimeType: "application/pdf", + sizeBytes: undefined, + revisionKey: "job_result_1", + }) + expect(mocks.readChunks).toHaveBeenCalledWith({ + documentId: "doc_remote", + revisionKey: "job_result_1", + page: 1, + pageSize: 1, + }) }) }) + +function makeReadySource(overrides: Record) { + return { + id: "00000000-0000-0000-0000-000000000002", + workspaceId: "workspace_1", + title: "notes.pdf", + mimeType: "application/pdf", + sizeBytes: 1024, + status: "ready", + failureReason: null, + failureStage: null, + knowhereJobId: "job_1", + knowhereDocumentId: "doc_1", + stagedBlobPathname: null, + stagedBlobUrl: null, + originalBlobPathname: null, + originalBlobUrl: null, + demoKey: null, + createdAt: new Date("2026-05-10T00:00:00.000Z"), + updatedAt: new Date("2026-05-10T00:00:00.000Z"), + deletedAt: null, + ...overrides, + } +} diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts index 6f82574..f531c23 100644 --- a/src/app/api/sources/[sourceId]/route.test.ts +++ b/src/app/api/sources/[sourceId]/route.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { ensureWorkspace: vi.fn(), fetchDemoCatalog: vi.fn(), findSourceInWorkspace: vi.fn(), + findByKnowhereDocumentId: vi.fn(), getCurrentUser: vi.fn(), hideDemoSource: vi.fn(), makeKnowhereClient: vi.fn(), @@ -55,6 +56,7 @@ vi.mock("@/domains/sources/background-reconcile", () => ({ vi.mock("@/domains/sources/service", () => ({ sourceService: { findInWorkspace: mocks.findSourceInWorkspace, + findByKnowhereDocumentId: mocks.findByKnowhereDocumentId, hideDemoSource: mocks.hideDemoSource, retrySourceToKnowhere: mocks.retrySourceToKnowhere, softDelete: mocks.softDeleteSource, @@ -116,11 +118,17 @@ describe("PATCH /api/sources/[sourceId]", () => { ); }); - it("rejects archive requests for unlocalized remote source ids", async () => { + it("archives unlocalized remote Knowhere documents", async () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); mocks.findSourceInWorkspace.mockResolvedValue(null); + mocks.findByKnowhereDocumentId.mockResolvedValue(null); mocks.fetchDemoCatalog.mockResolvedValue({ sources: [] }); + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); + mocks.makeKnowhereClient.mockReturnValue({ + documents: { archive: mocks.archive }, + }); + mocks.archive.mockResolvedValue(undefined); const response = await PATCH( new NextRequest( @@ -138,18 +146,71 @@ describe("PATCH /api/sources/[sourceId]", () => { ); await expect(response.json()).resolves.toEqual({ - message: "Source not found.", + id: "knowhere-doc:default:doc_remote", + archived: true, }); - expect(response.status).toBe(404); + expect(response.status).toBe(200); expect(mocks.findSourceInWorkspace).toHaveBeenCalledWith( "workspace_1", "knowhere-doc:default:doc_remote", ); - expect(mocks.archive).not.toHaveBeenCalled(); + expect(mocks.archive).toHaveBeenCalledWith("doc_remote"); + expect(mocks.findByKnowhereDocumentId).toHaveBeenCalledWith( + "workspace_1", + "doc_remote", + ); expect(mocks.softDeleteSource).not.toHaveBeenCalled(); expect(mocks.deleteBlob).not.toHaveBeenCalled(); }); + it("soft-deletes a matching local row when archiving a remote source id", async () => { + mocks.requireUser.mockResolvedValue({ id: "user_1" }); + mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); + mocks.findSourceInWorkspace.mockResolvedValue(null); + mocks.findByKnowhereDocumentId.mockResolvedValue({ + id: "source_1", + knowhereDocumentId: "doc_remote", + originalBlobPathname: "source-uploads/upload_1/document.pdf", + demoKey: null, + }); + mocks.fetchDemoCatalog.mockResolvedValue({ sources: [] }); + mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123"); + mocks.makeKnowhereClient.mockReturnValue({ + documents: { archive: mocks.archive }, + }); + mocks.archive.mockResolvedValue(undefined); + mocks.softDeleteSource.mockResolvedValue(true); + + const response = await PATCH( + new NextRequest( + "http://localhost:3001/api/sources/knowhere-doc:default:doc_remote", + { + method: "PATCH", + body: JSON.stringify({ archived: true }), + }, + ), + { + params: Promise.resolve({ + sourceId: "knowhere-doc:default:doc_remote", + }), + }, + ); + + await expect(response.json()).resolves.toEqual({ + id: "knowhere-doc:default:doc_remote", + archived: true, + }); + expect(response.status).toBe(200); + expect(mocks.archive).toHaveBeenCalledWith("doc_remote"); + expect(mocks.softDeleteSource).toHaveBeenCalledWith( + "workspace_1", + "source_1", + ); + expect(mocks.deleteBlob).toHaveBeenCalledWith( + "source-uploads/upload_1/document.pdf", + ); + }); + it("does not fail an already-soft-deleted source when original Blob cleanup fails", async () => { mocks.requireUser.mockResolvedValue({ id: "user_1" }); mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" }); diff --git a/src/app/api/sources/parsed-sync/route.ts b/src/app/api/sources/parsed-sync/route.ts new file mode 100644 index 0000000..5272176 --- /dev/null +++ b/src/app/api/sources/parsed-sync/route.ts @@ -0,0 +1,27 @@ +import { serve } from "@upstash/workflow/nextjs" + +import { parsedSyncRouteWorkflow } from "@/domains/sources/parsed-sync-route-workflow" + +type ParsedSyncPayload = Parameters< + typeof parsedSyncRouteWorkflow.normalizeParsedSyncPayload +>[0] + +export const { POST } = serve( + async (context) => { + const payload = parsedSyncRouteWorkflow.normalizeParsedSyncPayload( + context.requestPayload, + ) + await parsedSyncRouteWorkflow.runParsedSyncWorkflow({ + context, + payload, + }) + }, + { + failureFunction: async ({ context, failResponse }) => { + await parsedSyncRouteWorkflow.markSyncFailedAfterWorkflowFailure( + context.requestPayload, + failResponse, + ) + }, + }, +) diff --git a/src/app/api/sources/route.test.ts b/src/app/api/sources/route.test.ts index a78639b..a7b10c6 100644 --- a/src/app/api/sources/route.test.ts +++ b/src/app/api/sources/route.test.ts @@ -67,6 +67,7 @@ const source: Source = { sizeBytes: 5, status: "parsing", failureReason: null, + failureStage: null, knowhereJobId: "job_1", knowhereDocumentId: null, stagedBlobPathname: null, diff --git a/src/app/e2e/citation-same-page/page.tsx b/src/app/e2e/citation-same-page/page.tsx new file mode 100644 index 0000000..d2c16f9 --- /dev/null +++ b/src/app/e2e/citation-same-page/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from "next/navigation" + +import { WorkspaceShell } from "@/components/workspace-shell" +import type { ChatMessageView, ChatThreadView } from "@/domains/chat/types" +import type { SourceView } from "@/domains/sources/types" + +const sources: SourceView[] = [ + { + id: "source_spacex", + title: "spacex-s1.pdf", + mimeType: "application/pdf", + status: "ready", + documentId: "doc_spacex", + chunkCount: 1, + documentPresentation: { kind: "page-assets", pageCount: 26 }, + }, +] + +const chatThreads: ChatThreadView[] = [ + { + id: "thread_1", + title: "Same page citations", + createdAt: "2026-05-17T00:00:00.000Z", + updatedAt: "2026-05-17T00:00:00.000Z", + }, +] + +const chatMessages: ChatMessageView[] = [ + { + id: "assistant_1", + role: "assistant", + content: "Revenue grew [[cite:1]] and later expanded [[cite:2]].", + citations: [ + { + chunkType: "page", + score: 0.91, + pageCitationPageNumber: 26, + highlightRegions: [{ x: 0.12, y: 0.18, w: 0.46, h: 0.08 }], + source: { + documentId: "doc_spacex", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 26", + }, + }, + { + chunkType: "page", + score: 0.89, + pageCitationPageNumber: 26, + highlightRegions: [ + { x: 0.62, y: 0.52, w: 0.24, h: 0.06 }, + { x: 0.15, y: 0.68, w: 0.32, h: 0.05 }, + ], + source: { + documentId: "doc_spacex", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 26", + }, + }, + ], + }, +] + +export default function CitationSamePageTestPage() { + if (process.env.NODE_ENV === "production") notFound() + + return ( + + ) +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 5fd5cc7..409a3af 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -15,11 +15,11 @@ export default function GlobalError({ return ( - Something went wrong - Knowhere Notebook + Something went wrong - Knowhere Brain

- Notebook error + Brain error

Something went wrong diff --git a/src/app/globals.css b/src/app/globals.css index 42112a3..d2b9a67 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -43,6 +43,7 @@ --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); + --animate-citation-region-flash: citation-region-flash 4.6s ease-in-out forwards; --color-primary-light: var(--primary-light); --color-primary-dark: var(--primary-dark); --color-popover-foreground: var(--popover-foreground); @@ -96,6 +97,36 @@ animation: citation-pulse 1.2s ease-out; } +@keyframes citation-region-flash { + 0% { opacity: 0; } + 4.3% { opacity: 1; } + 8.7% { opacity: 0; } + 13% { opacity: 1; } + 17.4% { opacity: 0; } + 21.7% { opacity: 1; } + 82.6% { opacity: 1; } + 100% { opacity: 0; } +} + +.citation-region-flash { + z-index: 10; + display: block; + border-radius: var(--radius); + background-color: hsl(var(--ring) / 0.25); + animation: citation-region-flash 4.6s ease-in-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .citation-region-flash { + animation: citation-region-flash-reduced 3.6s ease-out forwards; + } + + @keyframes citation-region-flash-reduced { + 0%, 83% { opacity: 1; } + 100% { opacity: 0; } + } +} + @layer base { * { diff --git a/src/app/home-content.tsx b/src/app/home-content.tsx new file mode 100644 index 0000000..c2f1a64 --- /dev/null +++ b/src/app/home-content.tsx @@ -0,0 +1,28 @@ +import { connection } from "next/server" +import { WorkspaceShell } from "@/components/workspace-shell" +import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state" +import { effectOperation } from "@/lib/effect-operation" +import { summarizeUnknownError } from "@/lib/format-log-value" +import { logger } from "@/lib/logger" + +export async function HomeContent() { + await connection() + const initialState = await loadWorkspaceInitialState() + return +} + +async function loadWorkspaceInitialState(): ReturnType< + typeof loadWorkspaceShellInitialState +> { + try { + return await loadWorkspaceShellInitialState() + } catch (error) { + logger.error("workspace: initial state failed", { + error: summarizeUnknownError(error), + }) + throw effectOperation.createBoundaryError( + "Workspace initial state failed", + error, + ) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c802af3..8ae3bc7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -32,7 +32,7 @@ export async function LoginContent() {

- Knowhere Notebook + Knowhere Brain

({ loadWorkspaceShellInitialState: mocks.loadWorkspaceShellInitialState, })) -import { HomeContent } from "./page" +import { HomeContent } from "./home-content" import { makeWorkspaceInitialStateFailureFixture } from "@/test/workspace-initial-state-failure-fixture" describe("Home", () => { diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ebc597..0a3c967 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,5 @@ import { Suspense } from "react" -import { WorkspaceShell } from "@/components/workspace-shell" -import { loadWorkspaceShellInitialState } from "@/domains/workspace/initial-state" -import { effectOperation } from "@/lib/effect-operation" -import { summarizeUnknownError } from "@/lib/format-log-value" -import { logger } from "@/lib/logger" -import { connection } from "next/server" +import { HomeContent } from "./home-content" export default function Home() { return ( @@ -13,25 +8,3 @@ export default function Home() { ) } - -export async function HomeContent() { - await connection() - const initialState = await loadWorkspaceInitialState() - return -} - -async function loadWorkspaceInitialState(): ReturnType< - typeof loadWorkspaceShellInitialState -> { - try { - return await loadWorkspaceShellInitialState() - } catch (error) { - logger.error("workspace: initial state failed", { - error: summarizeUnknownError(error), - }) - throw effectOperation.createBoundaryError( - "Workspace initial state failed", - error, - ) - } -} diff --git a/src/components/chat-citation-model.test.ts b/src/components/chat-citation-model.test.ts new file mode 100644 index 0000000..b037bdf --- /dev/null +++ b/src/components/chat-citation-model.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest" + +import { chatCitationModel } from "./chat-citation-model" +import type { ChatCitationView, ChatMessageView } from "@/domains/chat/types" + +const pageCitation: ChatCitationView = { + chunkType: "page", + score: 0.9, + pageCitationPageNumber: 26, + source: { + documentId: "doc_1", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 26", + }, +} + +const samePageCitation: ChatCitationView = { + ...pageCitation, + score: 0.88, +} + +const otherFileCitation: ChatCitationView = { + chunkType: "page", + score: 0.8, + pageCitationPageNumber: 3, + source: { + documentId: "doc_2", + sourceFileName: "report.pdf", + sectionPath: "Page 3", + }, +} + +describe("chatCitationModel", () => { + it("labels chips as title/pN and keeps two same-page citations as two identities", () => { + expect( + chatCitationModel.getCitationChipLabel(pageCitation, { + doc_1: "spacex-s1.pdf", + }), + ).toBe("spacex-s1.pdf/p26") + + const groups = chatCitationModel.groupCitationsByFile( + "assistant_1", + [pageCitation, samePageCitation, otherFileCitation], + { + doc_1: "spacex-s1.pdf", + doc_2: "report.pdf", + }, + ) + + expect(groups).toHaveLength(2) + expect(groups[0]?.entries.map((entry) => entry.citationId)).toEqual([ + "assistant_1:0", + "assistant_1:1", + ]) + expect(groups[0]?.entries.map((entry) => entry.pageNumber)).toEqual([26, 26]) + expect( + chatCitationModel + .uniquePageLinkEntries(groups[0]!.entries) + .map((entry) => entry.pageNumber), + ).toEqual([26]) + expect(groups[1]?.title).toBe("report.pdf") + }) + + it("uses citation or same-page artifact boxes without inventing a region", () => { + const citation: ChatCitationView = { + chunkType: "page", + score: 0.9, + pageCitationPageNumber: 4, + source: { + documentId: "doc_1", + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "Page 4", + }, + } + + expect( + chatCitationModel.getListHighlightRegions( + { + ...citation, + highlightRegions: [{ x: 0.12, y: 0.18, w: 0.4, h: 0.1 }], + }, + [ + { + type: "image", + display: true, + highlightRegions: [{ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }], + citation: { + chunkType: "page", + score: 0.8, + pageCitationPageNumber: 4, + source: { documentId: "doc_1" }, + }, + }, + ], + ), + ).toEqual([{ x: 0.12, y: 0.18, w: 0.4, h: 0.1 }]) + + expect( + chatCitationModel.getListHighlightRegions(citation, [ + { + type: "image", + display: true, + highlightRegions: [{ x: 0.1, y: 0.2, w: 0.3, h: 0.15 }], + citation: { + chunkType: "page", + score: 0.8, + pageCitationPageNumber: 4, + source: { documentId: "doc_1" }, + }, + }, + { + type: "image", + display: true, + highlightRegions: [{ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }], + citation: { + chunkType: "page", + score: 0.7, + pageCitationPageNumber: 12, + source: { documentId: "doc_1" }, + }, + }, + ]), + ).toEqual([{ x: 0.1, y: 0.2, w: 0.3, h: 0.15 }]) + + expect( + chatCitationModel.getListHighlightRegions(citation, [ + { + type: "image", + display: true, + highlightRegions: [{ x: 0.7, y: 0.1, w: 0.2, h: 0.1 }], + citation: { + chunkType: "page", + score: 0.7, + pageCitationPageNumber: 4, + source: { documentId: "doc_2" }, + }, + }, + ]), + ).toEqual([]) + + expect( + chatCitationModel.getListHighlightRegions(citation, [ + { + type: "image", + display: false, + highlightRegions: [{ x: 0.7, y: 0.1, w: 0.2, h: 0.1 }], + citation: { + chunkType: "page", + score: 0.7, + pageCitationPageNumber: 4, + source: { documentId: "doc_1" }, + }, + }, + ]), + ).toEqual([]) + + expect(chatCitationModel.getListHighlightRegions(citation, [])).toEqual([]) + }) + + it("embeds cite markers as links and leaves fenced code unchanged", () => { + const markdown = chatCitationModel.embedCitationMarkersAsLinks( + [ + "Revenue grew [[cite:1]] and later [[cite:2]].", + "", + "```ts", + "const value = '[[cite:1]]'", + "```", + "", + "Legacy [Source 1: spacex-s1.pdf / Page 26] remains.", + ].join("\n"), + [pageCitation, samePageCitation], + { doc_1: "spacex-s1.pdf" }, + ) + + expect(markdown).toContain( + "[spacex-s1.pdf/p26](knowhere-cite://1)", + ) + expect(markdown).toContain( + "[spacex-s1.pdf/p26](knowhere-cite://2)", + ) + expect(markdown).toContain("const value = '[[cite:1]]'") + expect(markdown).not.toContain("[Source 1:") + expect(markdown).not.toContain("[[cite:1]] and later") + }) + + it("expands grouped cite markers into one chip per index", () => { + const markdown = chatCitationModel.embedCitationMarkersAsLinks( + "Total revenue was $24,901 million [[cite:1, 3, 5]].", + [pageCitation, samePageCitation, otherFileCitation, pageCitation, samePageCitation], + { + doc_1: "TSLA-Q4-2025-Update.pdf", + doc_2: "report.pdf", + }, + ) + + expect(markdown).toContain( + "[TSLA-Q4-2025-Update.pdf/p26](knowhere-cite://1)", + ) + expect(markdown).toContain("[report.pdf/p3](knowhere-cite://3)") + expect(markdown).toContain( + "[TSLA-Q4-2025-Update.pdf/p26](knowhere-cite://5)", + ) + expect(markdown).not.toContain("[[cite:") + }) + + it("drops unknown cite markers from display markdown", () => { + const markdown = chatCitationModel.embedCitationMarkersAsLinks( + "Unsupported claim [[cite:9]].", + [pageCitation], + { doc_1: "spacex-s1.pdf" }, + ) + + expect(markdown).toBe("Unsupported claim.") + expect(markdown).not.toContain("knowhere-cite://9") + }) + + it("strips markers from export markdown and keeps copy labels", () => { + const message: ChatMessageView = { + id: "assistant_1", + role: "assistant", + content: [ + "Revenue grew [[cite:1]].", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"), + citations: [pageCitation], + artifacts: [ + { + type: "derived_table", + display: true, + title: "Revenue", + columns: ["Year", "Amount"], + rows: [["2025", "4.4B"]], + }, + ], + } + + expect(chatCitationModel.getExportMarkdown(message)).toBe( + [ + "Revenue grew.", + "", + "```ts", + "const value = 1;", + "```", + "", + "### Revenue", + "| Year | Amount |", + "| --- | --- |", + "| 2025 | 4.4B |", + ].join("\n"), + ) + expect(chatCitationModel.getCopyMarkdown(message, { doc_1: "spacex-s1.pdf" })) + .toContain("Revenue grew spacex-s1.pdf/p26.") + expect(chatCitationModel.getCopyMarkdown(message, { doc_1: "spacex-s1.pdf" })) + .toContain("const value = 1;") + }) + + it("keeps knowhere cite hrefs out of the default markdown URL transform", () => { + expect( + chatCitationModel.transformMarkdownUrl("knowhere-cite://2", () => ""), + ).toBe("knowhere-cite://2") + expect(chatCitationModel.parseKnowhereCiteIndex("knowhere-cite://2")).toBe(2) + expect(chatCitationModel.isKnowhereCiteHref("https://example.com")).toBe( + false, + ) + }) +}) diff --git a/src/components/chat-citation-model.ts b/src/components/chat-citation-model.ts new file mode 100644 index 0000000..31181d5 --- /dev/null +++ b/src/components/chat-citation-model.ts @@ -0,0 +1,337 @@ +import { workspaceCitationState } from "@/components/workspace-citation-state" +import type { + ChatArtifactView, + ChatCitationView, + ChatImageHighlightBox, + ChatMessageView, +} from "@/domains/chat/types" + +export const knowhereCiteHrefPrefix = "knowhere-cite://" + +export type CitationSourceGroupEntry = { + readonly citation: ChatCitationView + readonly citationId: string + readonly citationIndex: number + readonly chipLabel: string + readonly pageNumber: number | null +} + +export type CitationSourceGroup = { + readonly key: string + readonly title: string + readonly documentId: string | null + readonly entries: readonly CitationSourceGroupEntry[] +} + +export const chatCitationModel = { + knowhereCiteHrefPrefix, + embedCitationMarkersAsLinks, + getCitationChipLabel, + getCopyMarkdown, + getExportMarkdown, + getListHighlightRegions, + getSourceTitle, + groupCitationsByFile, + isKnowhereCiteHref, + parseKnowhereCiteIndex, + stripCitationMarkers, + transformMarkdownUrl, + uniquePageLinkEntries, +} as const + +function embedCitationMarkersAsLinks( + content: string, + citations: readonly ChatCitationView[], + sourceTitlesByDocumentId: Readonly>, +): string { + return mapOutsideCodeFences(content, (text) => + collapseRemovedCitationSpace( + replaceCitationTokens(text, (index) => { + const citation = citations[index - 1] + if (!citation) return "" + return toCiteMarkdownLink( + getCitationChipLabel(citation, sourceTitlesByDocumentId), + index, + ) + }), + ), + ) +} + +function getCopyMarkdown( + message: ChatMessageView, + sourceTitlesByDocumentId: Readonly>, +): string { + const copiedBody = mapOutsideCodeFences(message.content, (text) => + collapseRemovedCitationSpace( + replaceCitationTokens(text, (index) => { + const citation = message.citations?.[index - 1] + if (!citation) return "" + return ` ${getCitationChipLabel(citation, sourceTitlesByDocumentId)}` + }), + ), + ) + + return appendDerivedTables(copiedBody, message.artifacts) +} + +function getExportMarkdown(message: ChatMessageView): string { + return appendDerivedTables(stripCitationMarkers(message.content), message.artifacts) +} + +function stripCitationMarkers(content: string): string { + return mapOutsideCodeFences(content, (text) => + collapseRemovedCitationSpace(replaceCitationTokens(text, () => "")), + ) +} + +function groupCitationsByFile( + messageId: string, + citations: readonly ChatCitationView[], + sourceTitlesByDocumentId: Readonly>, +): readonly CitationSourceGroup[] { + const groups: CitationSourceGroup[] = [] + const groupIndexByKey = new Map() + + for (const [citationIndex, citation] of citations.entries()) { + const title = getSourceTitle(citation, sourceTitlesByDocumentId) + const documentId = getTrimmedField(citation.source.documentId) + const key = documentId + ? `document:${documentId}` + : `file:${citationIndex}:${title}` + const entry: CitationSourceGroupEntry = { + citation, + citationId: `${messageId}:${citationIndex}`, + citationIndex, + chipLabel: getCitationChipLabel(citation, sourceTitlesByDocumentId), + pageNumber: workspaceCitationState.getCitationPageNumber(citation), + } + const existingIndex = groupIndexByKey.get(key) + if (existingIndex === undefined) { + groupIndexByKey.set(key, groups.length) + groups.push({ + key, + title, + documentId, + entries: [entry], + }) + continue + } + + const existing = groups[existingIndex] + if (!existing) continue + groups[existingIndex] = { + ...existing, + entries: [...existing.entries, entry], + } + } + + return groups +} + +function uniquePageLinkEntries( + entries: readonly CitationSourceGroupEntry[], +): readonly CitationSourceGroupEntry[] { + const seenPageNumbers = new Set() + const uniqueEntries: CitationSourceGroupEntry[] = [] + + for (const entry of entries) { + if (entry.pageNumber === null || seenPageNumbers.has(entry.pageNumber)) { + continue + } + seenPageNumbers.add(entry.pageNumber) + uniqueEntries.push(entry) + } + + return uniqueEntries +} + +function getListHighlightRegions( + citation: ChatCitationView, + artifacts: readonly ChatArtifactView[] | undefined, +): readonly ChatImageHighlightBox[] { + if (citation.highlightRegions && citation.highlightRegions.length > 0) { + return citation.highlightRegions + } + + const pageNumber = workspaceCitationState.getCitationPageNumber(citation) + const documentId = getTrimmedField(citation.source.documentId) + if (pageNumber === null || !documentId) return [] + + const matchingRegions = (artifacts ?? []).flatMap((artifact) => { + if ( + artifact.type !== "image" || + artifact.display === false || + !artifact.citation || + !artifact.highlightRegions || + artifact.highlightRegions.length === 0 + ) { + return [] + } + + const artifactDocumentId = getTrimmedField( + artifact.citation.source.documentId, + ) + const artifactPage = workspaceCitationState.getCitationPageNumber( + artifact.citation, + ) + if (artifactDocumentId !== documentId || artifactPage !== pageNumber) { + return [] + } + + return [...artifact.highlightRegions] + }) + + if (matchingRegions.length > 0) return matchingRegions + return [] +} + +function getCitationChipLabel( + citation: ChatCitationView, + sourceTitlesByDocumentId: Readonly>, +): string { + const title = getSourceTitle(citation, sourceTitlesByDocumentId) + const pageNumber = workspaceCitationState.getCitationPageNumber(citation) + return pageNumber === null ? title : `${title}/p${pageNumber}` +} + +function getSourceTitle( + citation: ChatCitationView, + sourceTitlesByDocumentId: Readonly>, +): string { + const documentId = getTrimmedField(citation.source.documentId) + const sourceTitle = documentId + ? getTrimmedField(sourceTitlesByDocumentId[documentId]) + : null + if (sourceTitle) return sourceTitle + + const sourceFileName = getTrimmedField(citation.source.sourceFileName) + if (sourceFileName && !isGeneratedKnowhereFileName(sourceFileName)) { + return sourceFileName + } + + return "Source" +} + +function isKnowhereCiteHref(href: string | undefined): boolean { + return typeof href === "string" && href.startsWith(knowhereCiteHrefPrefix) +} + +function parseKnowhereCiteIndex(href: string | undefined): number | null { + if (!href || !href.startsWith(knowhereCiteHrefPrefix)) return null + const value = href.slice(knowhereCiteHrefPrefix.length) + const index = Number.parseInt(value, 10) + if (!Number.isSafeInteger(index) || index < 1) return null + return index +} + +function transformMarkdownUrl(value: string, transformDefault: (next: string) => string): string { + if (isKnowhereCiteHref(value)) return value + return transformDefault(value) +} + +function replaceCitationTokens( + text: string, + replaceIndex: (index: number) => string, +): string { + return text + .replace( + /\[\[cite:\s*(\d+(?:\s*,\s*\d+)*)\s*\]\]/g, + (_match, rawIndices: string) => + parseCiteIndices(rawIndices) + .map((index) => replaceIndex(index)) + .filter((part) => part.length > 0) + .join(" "), + ) + .replace(/\[Source\s+(\d+)\s*:\s*[^\]]*\]/g, (_match, rawIndex: string) => { + const index = Number.parseInt(rawIndex, 10) + if (!Number.isSafeInteger(index) || index < 1) return "" + return replaceIndex(index) + }) +} + +function parseCiteIndices(rawIndices: string): readonly number[] { + return rawIndices.split(",").flatMap((rawIndex) => { + const index = Number.parseInt(rawIndex.trim(), 10) + if (!Number.isSafeInteger(index) || index < 1) return [] + return [index] + }) +} + +function toCiteMarkdownLink(label: string, index: number): string { + return `[${escapeMarkdownLinkLabel(label)}](${knowhereCiteHrefPrefix}${index})` +} + +function escapeMarkdownLinkLabel(label: string): string { + return label.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]") +} + +function collapseRemovedCitationSpace(text: string): string { + return text.replaceAll(" ", " ").replace(/ +([.,;:])/g, "$1").trimEnd() +} + +function mapOutsideCodeFences( + content: string, + mapText: (text: string) => string, +): string { + const lines = content.split("\n") + const output: string[] = [] + let fenceMarker: string | null = null + + for (const line of lines) { + if (fenceMarker) { + output.push(line) + if (line.startsWith(fenceMarker)) fenceMarker = null + continue + } + + const fenceMatch = /^(```+|~~~+)/.exec(line) + if (fenceMatch) { + fenceMarker = fenceMatch[1] ?? null + output.push(line) + continue + } + + output.push(mapText(line)) + } + + return output.join("\n") +} + +function appendDerivedTables( + markdown: string, + artifacts: readonly ChatArtifactView[] | undefined, +): string { + const tables = (artifacts ?? []).flatMap((artifact, index) => { + if (artifact.display === false || artifact.type !== "derived_table") return [] + if (!artifact.title || !artifact.columns || !artifact.rows) return [] + return [formatDerivedTable(artifact, index)] + }) + if (tables.length === 0) return markdown + const body = markdown.trimEnd() + return body.length > 0 ? `${body}\n\n${tables.join("\n\n")}` : tables.join("\n\n") +} + +function formatDerivedTable( + artifact: ChatArtifactView, + index: number, +): string { + const columns = artifact.columns ?? [] + const header = `| ${columns.join(" | ")} |` + const separator = `| ${columns.map(() => "---").join(" | ")} |` + const rows = (artifact.rows ?? []).map( + (row) => `| ${columns.map((_, columnIndex) => row[columnIndex] ?? "").join(" | ")} |`, + ) + return [`### ${artifact.title ?? `Table ${index + 1}`}`, header, separator, ...rows].join( + "\n", + ) +} + +function getTrimmedField(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? "" + return trimmed.length > 0 ? trimmed : null +} + +function isGeneratedKnowhereFileName(value: string): boolean { + return /^document-[A-Za-z0-9_-]{16,}\.[A-Za-z0-9]+$/u.test(value) +} diff --git a/src/components/chat-composer.test.ts b/src/components/chat-composer.test.ts index 1d7051b..a12264a 100644 --- a/src/components/chat-composer.test.ts +++ b/src/components/chat-composer.test.ts @@ -28,10 +28,51 @@ describe("ChatComposer", () => { await user.type(input, " Summarize this document "); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize this document"); + expect(onSend).toHaveBeenCalledWith("Summarize this document", { + useAgentic: true, + }); expect(input.value).toBe(""); }); + it("defaults to deep search enabled and explains the toggle", async () => { + const user = userEvent.setup(); + + render(React.createElement(ChatComposer)); + + const toggle = screen.getByRole("checkbox", { + name: "Deep search", + }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + await user.hover(toggle); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip.textContent).toContain( + "Deep search plans document selection and navigation", + ); + }); + + it("sends useAgentic false after toggling deep search off", async () => { + const user = userEvent.setup(); + const onSend = vi.fn(); + + render(React.createElement(ChatComposer, { onSend })); + + const toggle = screen.getByRole("checkbox", { + name: "Deep search", + }); + await user.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + + const input = getComposerTextArea(); + await user.type(input, "Quick summary"); + await user.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("Quick summary", { + useAgentic: false, + }); + }); + it("caps long prompts and resets the composer after sending", async () => { const user = userEvent.setup(); const onSend = vi.fn(); diff --git a/src/components/chat-composer.tsx b/src/components/chat-composer.tsx index 4cfa62c..4aab263 100644 --- a/src/components/chat-composer.tsx +++ b/src/components/chat-composer.tsx @@ -13,6 +13,7 @@ import { import { BarChart3, FileText, Plus, Send } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +23,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { chatPromptTemplates } from "@/domains/chat/prompt-templates"; const chatComposerName = "chat-composer"; const chatComposerTextAreaMinHeight = 128; @@ -33,6 +40,10 @@ type TextRange = { readonly end: number; }; +export type ChatSendOptions = { + readonly useAgentic: boolean; +}; + export type ChatComposerProps = { readonly canCreateDiagram?: boolean; readonly isDisabled?: boolean; @@ -40,7 +51,7 @@ export type ChatComposerProps = { readonly isSending?: boolean; readonly onCreateDiagram?: () => void; readonly onLoginClick?: () => void; - readonly onSend?: (text: string) => void; + readonly onSend?: (text: string, options: ChatSendOptions) => void; }; export function ChatComposer({ @@ -53,6 +64,7 @@ export function ChatComposer({ onSend, }: ChatComposerProps): ReactElement { const [input, setInput] = useState(""); + const [useAgentic, setUseAgentic] = useState(true); const composerInputId = useId(); const pendingTemplatePromptRef = useRef(null); const textareaRef = useRef(null); @@ -79,7 +91,7 @@ export function ChatComposer({ function handleSend(): void { if (!canSend) return; - onSend?.(trimmedInput); + onSend?.(trimmedInput, { useAgentic }); setInput(""); } @@ -177,22 +189,53 @@ export function ChatComposer({ onCreateDiagram={onCreateDiagram} onTemplateSelect={handleTemplateSelect} /> - +
+ + + + + + + Deep search plans document selection and navigation for + more thorough answers. Turn off for faster classic search. + + + + +
)} diff --git a/src/components/chat-message-export-pdf.test.ts b/src/components/chat-message-export-pdf.test.ts new file mode 100644 index 0000000..8bbe53c --- /dev/null +++ b/src/components/chat-message-export-pdf.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +import { createPdfBlobFromMarkdown } from "./chat-message-export-pdf" + +describe("createPdfBlobFromMarkdown", () => { + it("builds a PDF without citation markers", async () => { + const blob = createPdfBlobFromMarkdown("Revenue grew in the quarter.") + const text = await blob.text() + + expect(blob.type).toBe("application/pdf") + expect(text.startsWith("%PDF-1.4")).toBe(true) + expect(text).toContain("Revenue grew in the quarter.") + expect(text).not.toContain("[[cite:") + expect(text).not.toContain("SOURCES") + }) +}) diff --git a/src/components/chat-message-export-pdf.ts b/src/components/chat-message-export-pdf.ts new file mode 100644 index 0000000..4c4aa3b --- /dev/null +++ b/src/components/chat-message-export-pdf.ts @@ -0,0 +1,136 @@ +const pdfPageWidth = 612 +const pdfPageHeight = 792 +const pdfMargin = 54 +const pdfFontSize = 11 +const pdfLineHeight = 14 +const pdfCharsPerLine = 90 + +export async function downloadAnswerPdf( + filename: string, + markdown: string, +): Promise { + const blob = createPdfBlobFromMarkdown(markdown) + downloadBlob(filename, blob) +} + +export function createPdfBlobFromMarkdown(markdown: string): Blob { + const lines = wrapPdfLines(toWinAnsi(markdown), pdfCharsPerLine) + const linesPerPage = Math.max( + 1, + Math.floor((pdfPageHeight - pdfMargin * 2) / pdfLineHeight), + ) + const pageCount = Math.max(1, Math.ceil(lines.length / linesPerPage)) + const objects: string[] = [] + const pageObjectNumbers: number[] = [] + const fontObjectNumber = 3 + pageCount * 2 + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + const pageLines = lines.slice( + pageIndex * linesPerPage, + (pageIndex + 1) * linesPerPage, + ) + const content = buildPageContent(pageLines) + const contentObjectNumber = 3 + pageIndex * 2 + const pageObjectNumber = contentObjectNumber + 1 + objects.push(pdfObject(contentObjectNumber, `<< /Length ${content.length} >>\nstream\n${content}\nendstream`)) + objects.push( + pdfObject( + pageObjectNumber, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pdfPageWidth} ${pdfPageHeight}] /Contents ${contentObjectNumber} 0 R /Resources << /Font << /F1 ${fontObjectNumber} 0 R >> >> >>`, + ), + ) + pageObjectNumbers.push(pageObjectNumber) + } + + objects.unshift( + pdfObject(1, "<< /Type /Catalog /Pages 2 0 R >>"), + pdfObject( + 2, + `<< /Type /Pages /Count ${pageCount} /Kids [${pageObjectNumbers + .map((objectNumber) => `${objectNumber} 0 R`) + .join(" ")}] >>`, + ), + ) + objects.push( + pdfObject(fontObjectNumber, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"), + ) + + return buildPdf(objects) +} + +function buildPageContent(lines: readonly string[]): string { + const commands = [ + "BT", + `/F1 ${pdfFontSize} Tf`, + `${pdfMargin} ${pdfPageHeight - pdfMargin} Td`, + `${pdfLineHeight} TL`, + ] + for (const [index, line] of lines.entries()) { + commands.push(`(${escapePdfLiteral(line)}) Tj`) + if (index < lines.length - 1) commands.push("T*") + } + commands.push("ET") + return commands.join("\n") +} + +function wrapPdfLines(text: string, maxChars: number): string[] { + const lines: string[] = [] + for (const paragraph of text.split(/\r?\n/)) { + if (paragraph.length === 0) { + lines.push("") + continue + } + + let remaining = paragraph + while (remaining.length > maxChars) { + let breakAt = remaining.lastIndexOf(" ", maxChars) + if (breakAt < 1) breakAt = maxChars + lines.push(remaining.slice(0, breakAt)) + remaining = remaining.slice(breakAt).trimStart() + } + lines.push(remaining) + } + return lines.length > 0 ? lines : [""] +} + +function toWinAnsi(text: string): string { + return Array.from(text, (character) => + character.charCodeAt(0) < 256 ? character : "?", + ).join("") +} + +function escapePdfLiteral(text: string): string { + return text.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)") +} + +function pdfObject(objectNumber: number, body: string): string { + return `${objectNumber} 0 obj\n${body}\nendobj` +} + +function buildPdf(objects: readonly string[]): Blob { + const xrefOffsets: number[] = [0] + let body = "%PDF-1.4\n" + for (const object of objects) { + xrefOffsets.push(body.length) + body += `${object}\n` + } + const xrefStart = body.length + const xrefEntries = [ + "xref", + `0 ${objects.length + 1}`, + "0000000000 65535 f ", + ...xrefOffsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n `), + ] + body += `${xrefEntries.join("\n")}\n` + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n` + return new Blob([body], { type: "application/pdf" }) +} + +function downloadBlob(filename: string, blob: Blob): void { + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = objectUrl + anchor.download = filename + anchor.click() + URL.revokeObjectURL(objectUrl) +} diff --git a/src/components/chat-message-list.test.ts b/src/components/chat-message-list.test.ts index 8d58630..f62632e 100644 --- a/src/components/chat-message-list.test.ts +++ b/src/components/chat-message-list.test.ts @@ -70,13 +70,14 @@ describe("ChatMessageList", () => { id: "assistant_1", role: "assistant", content: [ - "Capital expenditure appears in the appendix. [Source 1: spacex-s1.pdf / Assets / tables / table-25 Capital Expenditures.html]", - "Drivers are discussed elsewhere. [Source 3: spacex-s1.pdf / MD&A / Drivers of Our Performance]", + "Capital expenditure appears in the appendix. [[cite:1]]", + "Drivers are discussed elsewhere. [[cite:3]]", ].join("\n\n"), citations: [ { chunkType: "table", score: 0.9, + pageCitationPageNumber: 25, source: { documentId: "doc_1", sourceFileName: "spacex-s1.pdf", @@ -87,6 +88,7 @@ describe("ChatMessageList", () => { { chunkType: "table", score: 0.91, + pageCitationPageNumber: 25, source: { documentId: "doc_1", sourceFileName: "spacex-s1.pdf", @@ -97,6 +99,7 @@ describe("ChatMessageList", () => { { chunkType: "text", score: 0.8, + pageCitationPageNumber: 40, source: { documentId: "doc_1", sourceFileName: "spacex-s1.pdf", @@ -110,16 +113,27 @@ describe("ChatMessageList", () => { }), ); - expect(screen.getByText("Capital expenditure appears in the appendix.")) + expect(screen.getByText(/Capital expenditure appears in the appendix./u)) .toBeTruthy(); expect(screen.queryByText(/Source 1/u)).toBeNull(); - expect(screen.queryByText(/Source 3/u)).toBeNull(); + expect(screen.queryByText(/\[\[cite:/u)).toBeNull(); expect(screen.getByText("Sources")).toBeTruthy(); - const sourceChips = screen.getAllByRole("button", { - name: "Open source spacex-s1.pdf", - }); + const sourceChips = screen.getAllByTestId("citation-chip"); expect(sourceChips).toHaveLength(2); + expect(sourceChips[0]?.textContent).toBe("spacex-s1.pdf/p25"); + expect(sourceChips[1]?.textContent).toBe("spacex-s1.pdf/p40"); + expect(sourceChips[0]?.className).toContain("bg-muted"); + expect(sourceChips[0]?.className).toContain("h-5"); + expect( + screen.getByRole("button", { name: "Open source spacex-s1.pdf" }), + ).toBeTruthy(); + expect( + screen.getAllByRole("button", { name: "Open page 25 of spacex-s1.pdf" }), + ).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Open page 40 of spacex-s1.pdf" }), + ).toBeTruthy(); await user.hover(sourceChips[0]!); @@ -129,7 +143,101 @@ describe("ChatMessageList", () => { ); }); - it("removes description-only source labels without changing other markdown whitespace", () => { + it("renders a separate page image link without replacing source focus", async () => { + const user = userEvent.setup(); + const onCitationClick = vi.fn(); + + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "The referenced page discusses revenue.", + citations: [ + { + chunkType: "page", + score: 0.9, + pageCitationAssetUrl: + "https://blob.example/pages/page-000004.png", + pageCitationPageNumber: 4, + source: { + documentId: "doc_1", + sourceFileName: "report.pdf", + sectionPath: "Page 4", + }, + }, + ], + }, + ], + onCitationClick, + }), + ); + + const citationButton = screen.getByRole("button", { + name: "Open page 4 of report.pdf", + }); + expect( + screen.queryByRole("link", { + name: "Open page image for report.pdf", + }), + ).toBeNull(); + + await user.click(citationButton); + expect(onCitationClick).toHaveBeenCalledWith( + expect.objectContaining({ + pageCitationAssetUrl: "https://blob.example/pages/page-000004.png", + pageCitationPageNumber: 4, + }), + "assistant_1:0", + [], + ); + }); + + it("passes persisted citation regions to page navigation", async () => { + const user = userEvent.setup(); + const onCitationClick = vi.fn(); + const regions = [ + { x: 0.19, y: 0.16, w: 0.51, h: 0.03 }, + { x: 0.04, y: 0.29, w: 0.15, h: 0.03 }, + ]; + + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_regions", + role: "assistant", + content: "Revenue was $24.9B [[cite:1]].", + citations: [ + { + chunkType: "page", + score: 0.9, + pageCitationPageNumber: 4, + highlightRegions: regions, + source: { + documentId: "doc_tsla", + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "FINANCIAL SUMMARY", + }, + }, + ], + }, + ], + onCitationClick, + }), + ); + + await user.click(screen.getByTestId("citation-chip")); + + expect(onCitationClick).toHaveBeenCalledWith( + expect.objectContaining({ pageCitationPageNumber: 4 }), + "assistant_regions:0", + regions, + ); + }); + + it("renders inline chips for leftover Source tokens without rewriting fenced code", () => { render( React.createElement(ChatMessageList, { messages: [ @@ -148,6 +256,7 @@ describe("ChatMessageList", () => { chunkType: "text", score: 0.9, description: "revenue growth", + pageCitationPageNumber: 2, source: { documentId: "doc_1", sourceFileName: "notes.pdf", @@ -160,13 +269,56 @@ describe("ChatMessageList", () => { }), ); - expect(screen.getByText("Revenue improved.")).toBeTruthy(); + expect(screen.getByText(/Revenue improved/u)).toBeTruthy(); expect(screen.queryByText(/Source 1/u)).toBeNull(); + expect(screen.getByRole("button", { name: "Open source notes.pdf/p2" })) + .toBeTruthy(); expect(document.querySelector("code.language-ts")?.textContent).toContain( "const value = 1;", ); }); + it("keeps two same-page citation chips as separate buttons", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "First claim [[cite:1]] and second claim [[cite:2]].", + citations: [ + { + chunkType: "page", + score: 0.9, + pageCitationPageNumber: 2, + source: { + documentId: "doc_1", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 2", + }, + }, + { + chunkType: "page", + score: 0.88, + pageCitationPageNumber: 2, + source: { + documentId: "doc_1", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 2", + }, + }, + ], + }, + ], + }), + ); + + expect( + screen.getAllByRole("button", { name: "Open source spacex-s1.pdf/p2" }), + ).toHaveLength(2); + expect(screen.getAllByTestId("citation-chip")).toHaveLength(2); + }); + it("preserves repeated spaces when there are no citation tokens to remove", () => { render( React.createElement(ChatMessageList, { @@ -293,6 +445,62 @@ describe("ChatMessageList", () => { expect(screen.queryByRole("img", { name: "其他候选图片" })).toBeNull(); }); + it("renders multi-region answer highlights on displayed page artifacts", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "风险辨识要求建立分级管控制度。", + artifacts: [ + { + type: "image", + display: true, + assetUrl: "https://blob.example/pages/page-225.png", + label: "page 225", + highlightRegions: [ + { x: 0.1, y: 0.2, w: 0.4, h: 0.1 }, + { x: 0.2, y: 0.5, w: 0.5, h: 0.12 }, + ], + }, + ], + }, + ], + }), + ); + + expect(screen.getByTestId("chat-image-highlights")).toBeTruthy(); + expect(screen.getAllByTestId("chat-image-highlight-region")).toHaveLength(2); + }); + + it("keeps the original image layout when artifacts have no highlight regions", () => { + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "Here is the page.", + artifacts: [ + { + type: "image", + display: true, + assetUrl: "https://blob.example/pages/page-1.png", + label: "page 1", + }, + ], + }, + ], + }), + ); + + const image = screen.getByRole("img", { name: "page 1" }); + expect(image.className).toContain("object-contain"); + expect(image.className).toContain("w-full"); + expect(screen.queryByTestId("chat-image-highlights")).toBeNull(); + }); + it("does not fall back to image citations when a harness message has empty artifacts", () => { render( React.createElement(ChatMessageList, { @@ -483,4 +691,50 @@ describe("ChatMessageList", () => { within(screen.getByTestId("chat-scroll")).getByText("What changed?"), ).toBeTruthy(); }); + + it("copies visible answer text and keeps export markdown citation-stripped", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render( + React.createElement(ChatMessageList, { + messages: [ + { + id: "assistant_1", + role: "assistant", + content: "Revenue grew [[cite:1]].", + citations: [ + { + chunkType: "page", + score: 0.9, + pageCitationPageNumber: 26, + source: { + documentId: "doc_1", + sourceFileName: "spacex-s1.pdf", + sectionPath: "Page 26", + }, + }, + ], + }, + ], + sourceTitlesByDocumentId: { doc_1: "spacex-s1.pdf" }, + }), + ); + + await user.click(screen.getByRole("button", { name: "Copy answer" })); + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining("Revenue grew spacex-s1.pdf/p26."), + ); + expect(writeText.mock.calls[0]?.[0]).not.toContain("[[cite:"); + expect( + screen.getByRole("button", { name: "Download answer as Markdown" }), + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Download answer as PDF" }), + ).toBeTruthy(); + }); }); diff --git a/src/components/chat-message-list.tsx b/src/components/chat-message-list.tsx index e5e6460..796f2ea 100644 --- a/src/components/chat-message-list.tsx +++ b/src/components/chat-message-list.tsx @@ -2,16 +2,18 @@ import { type CSSProperties, type ReactElement } from "react"; import { type VirtualItem } from "@tanstack/react-virtual"; -import { ImageIcon, MessageCircle } from "lucide-react"; +import { Copy, Download, FileText, ImageIcon, MessageCircle } from "lucide-react"; import ReactMarkdown, { defaultUrlTransform, type Components, } from "react-markdown"; import remarkGfm from "remark-gfm"; +import { chatCitationModel } from "@/components/chat-citation-model"; import { ChatDiagramCard } from "@/components/chat-diagram-card"; import { useChatMessageListWorkflow } from "@/components/chat-message-list-workflow"; import { chatPanelModel } from "@/components/chat-panel-model"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Spinner } from "@/components/ui/spinner"; import { @@ -24,24 +26,24 @@ import type { ChatDiagramChartSpec } from "@/domains/chat/diagram"; import type { ChatArtifactView, ChatCitationView, + ChatImageHighlightBox, ChatMessageView, } from "@/domains/chat/types"; -type DisplayCitation = { +type DisplayImageCitation = { readonly citation: ChatCitationView; readonly citationId: string; readonly label: string; readonly tooltipLabel: string; -}; - -type DisplayImageCitation = DisplayCitation & { readonly assetUrl: string; + readonly highlightRegions?: ChatArtifactView["highlightRegions"]; }; type DisplayImageArtifact = { readonly assetUrl: string; readonly citationId: string; readonly label: string; + readonly highlightRegions?: ChatArtifactView["highlightRegions"]; }; type DisplayDerivedTableArtifact = { @@ -88,6 +90,7 @@ export type ChatMessageListProps = { readonly onCitationClick?: ( citation: ChatCitationView, citationId: string, + highlightRegions?: readonly ChatImageHighlightBox[], ) => void; readonly pendingCitationId?: string | null; readonly pendingStatusText?: string | null; @@ -222,6 +225,7 @@ function VirtualMessageRow({ readonly onCitationClick?: ( citation: ChatCitationView, citationId: string, + highlightRegions?: readonly ChatImageHighlightBox[], ) => void; readonly pendingCitationId?: string | null; readonly sourceTitlesByDocumentId: Readonly>; @@ -230,6 +234,19 @@ function VirtualMessageRow({ return null; } + const boundCitationClick = onCitationClick + ? (citation: ChatCitationView, citationId: string): void => { + onCitationClick( + citation, + citationId, + chatCitationModel.getListHighlightRegions( + citation, + message.artifacts, + ), + ) + } + : undefined + const rowStyle: CSSProperties = { position: "absolute", transform: `translateY(${virtualItem.start}px)`, @@ -246,7 +263,7 @@ function VirtualMessageRow({ @@ -306,10 +323,6 @@ function MessageBubble({ ); } - const displayCitations = getDisplayCitations( - message, - sourceTitlesByDocumentId, - ); const displayImageArtifacts = getDisplayImageArtifacts( message, sourceTitlesByDocumentId, @@ -319,7 +332,7 @@ function MessageBubble({ ? displayImageArtifacts : getDisplayImageCitations(message, sourceTitlesByDocumentId); const displayDerivedTables = getDisplayDerivedTableArtifacts(message); - const citationContentMarkdown = buildCitationContentMarkdown( + const citationContentMarkdown = chatCitationModel.embedCitationMarkersAsLinks( message.content, message.citations ?? [], sourceTitlesByDocumentId, @@ -327,9 +340,14 @@ function MessageBubble({ return (
+
{displayDerivedTables.length > 0 && (
@@ -349,16 +367,16 @@ function MessageBubble({ Images

- {displayImageCitations.map(({ assetUrl, citationId, label }) => ( + {displayImageCitations.map( + ({ assetUrl, citationId, label, highlightRegions }) => (
- {/* eslint-disable-next-line @next/next/no-img-element -- Chat image citation dimensions are not known before render. */} - {label}
@@ -366,16 +384,23 @@ function MessageBubble({
- ))} + ), + )}
)}
+
+
); } @@ -418,78 +443,6 @@ function AssistantDiagram({ ); } -function buildCitationContentMarkdown( - content: string, - citations: readonly ChatCitationView[], - sourceTitlesByDocumentId: Readonly>, -): string { - let rewrittenContent = content; - - for (const [index, citation] of citations.entries()) { - const displayCitation = { - citation, - citationId: "", - label: getCitationSourceChipLabel(citation, sourceTitlesByDocumentId), - tooltipLabel: chatPanelModel.getCitationLabel( - citation, - sourceTitlesByDocumentId, - ), - }; - for (const token of getInlineCitationTokens(displayCitation, index)) { - if (!rewrittenContent.includes(token)) continue; - - rewrittenContent = removeInlineCitationToken(rewrittenContent, token); - } - } - - return rewrittenContent; -} - -function removeInlineCitationToken(content: string, token: string): string { - return content - .replaceAll(` ${token}`, "") - .replaceAll(`${token} `, "") - .replaceAll(token, ""); -} - -function getInlineCitationTokens( - displayCitation: DisplayCitation, - index: number, -): readonly string[] { - const label = displayCitation.label; - const slashLabel = label.replace(/\s+·\s+/gu, " / "); - const sourceName = getCitationSourceName(label); - const sectionPath = getTrimmedCitationField( - displayCitation.citation.source.sectionPath, - ); - const description = getTrimmedCitationField(displayCitation.citation.description); - const sectionLabel = sectionPath ? `${sourceName} / ${sectionPath}` : null; - const descriptionLabel = description ? `${sourceName} / ${description}` : null; - const citationNumber = index + 1; - const tokens = [ - `[${label}]`, - `[${slashLabel}]`, - `[Source ${citationNumber}: ${label}]`, - `[Source ${citationNumber}: ${slashLabel}]`, - sectionLabel ? `[${sectionLabel}]` : null, - sectionLabel ? `[Source ${citationNumber}: ${sectionLabel}]` : null, - descriptionLabel ? `[${descriptionLabel}]` : null, - descriptionLabel ? `[Source ${citationNumber}: ${descriptionLabel}]` : null, - description ? `[${description}]` : null, - description ? `[Source ${citationNumber}: ${description}]` : null, - ]; - - return Array.from( - new Set(tokens.filter((token): token is string => Boolean(token))), - ).sort((left, right): number => right.length - left.length); -} - -function getCitationSourceName(label: string): string { - const [sourceName] = label.split(/\s+·\s+/u); - const normalized = sourceName?.trim(); - return normalized && normalized.length > 0 ? normalized : label; -} - function DerivedTableArtifactView({ artifact, }: { @@ -540,12 +493,49 @@ function DerivedTableArtifactView({ function AssistantMessageContent({ content, + message, + onCitationClick, + pendingCitationId, + sourceTitlesByDocumentId, }: { readonly content: string; + readonly message: ChatMessageView; + readonly onCitationClick?: ( + citation: ChatCitationView, + citationId: string, + ) => void; + readonly pendingCitationId?: string | null; + readonly sourceTitlesByDocumentId: Readonly>; }): ReactElement { const markdownComponents: Components = { ...assistantMarkdownComponents, a: ({ href, children }) => { + const citationIndex = chatCitationModel.parseKnowhereCiteIndex(href); + if (citationIndex !== null) { + const citation = (message.citations ?? [])[citationIndex - 1]; + if (!citation) return null; + const citationId = chatPanelModel.getCitationId( + message.id, + citationIndex - 1, + ); + return ( + + ); + } + return ( void; readonly pendingCitationId?: string | null; + readonly sourceTitlesByDocumentId: Readonly>; }): ReactElement | null { - if (displayCitations.length === 0) return null; + const groups = chatCitationModel.groupCitationsByFile( + message.id, + message.citations ?? [], + sourceTitlesByDocumentId, + ); + if (groups.length === 0) return null; return (
@@ -593,26 +590,148 @@ function AssistantSources({ Sources

-
- {displayCitations.map((displayCitation) => ( - +
    + {groups.map((group, groupIndex) => ( +
  1. + + {groupIndex + 1}. + + entry.citationId === pendingCitationId, + )} + onCitationClick={onCitationClick} + /> + {chatCitationModel.uniquePageLinkEntries(group.entries).map( + (entry) => ( + + ), + )} +
  2. ))} -
+
); } +function CitationSourceTitle({ + group, + isPending, + onCitationClick, +}: { + readonly group: ReturnType[number]; + readonly isPending: boolean; + readonly onCitationClick?: ( + citation: ChatCitationView, + citationId: string, + ) => void; +}): ReactElement { + const firstEntry = group.entries[0]; + if (!firstEntry) { + return {group.title}; + } + + return ( + + ); +} + +function AssistantMessageActions({ + message, + sourceTitlesByDocumentId, +}: { + readonly message: ChatMessageView; + readonly sourceTitlesByDocumentId: Readonly>; +}): ReactElement { + const copyMarkdown = chatCitationModel.getCopyMarkdown( + message, + sourceTitlesByDocumentId, + ); + const exportMarkdown = chatCitationModel.getExportMarkdown(message); + + return ( +
+ + + +
+ ); +} + +function downloadTextFile(filename: string, contents: string): void { + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(objectUrl); +} + function transformAssistantMarkdownUrl(value: string): string { - return defaultUrlTransform(value); + return chatCitationModel.transformMarkdownUrl(value, defaultUrlTransform); } function CitationChip({ @@ -634,82 +753,34 @@ function CitationChip({ ) => void; }): ReactElement { return ( - - - + + - {label} - - - - {tooltipLabel} - - + {tooltipLabel} + + + ); } -function getDisplayCitations( - message: ChatMessageView, - sourceTitlesByDocumentId: Readonly>, -): readonly DisplayCitation[] { - const seenKeys = new Set(); - const displayCitations: DisplayCitation[] = []; - - for (const [index, citation] of (message.citations ?? []).entries()) { - const label = getCitationSourceChipLabel( - citation, - sourceTitlesByDocumentId, - ); - const key = getCitationDisplayKey(citation, label); - if (seenKeys.has(key)) continue; - - seenKeys.add(key); - displayCitations.push({ - citation, - citationId: chatPanelModel.getCitationId(message.id, index), - label, - tooltipLabel: chatPanelModel.getCitationLabel( - citation, - sourceTitlesByDocumentId, - ), - }); - } - - return displayCitations; -} - -function getCitationSourceChipLabel( - citation: ChatCitationView, - sourceTitlesByDocumentId: Readonly>, -): string { - const documentId = getTrimmedCitationField(citation.source.documentId); - const sourceTitle = documentId - ? getTrimmedCitationField(sourceTitlesByDocumentId[documentId]) - : null; - if (sourceTitle) return sourceTitle; - - const sourceFileName = getTrimmedCitationField(citation.source.sourceFileName); - if (sourceFileName && !isGeneratedKnowhereFileName(sourceFileName)) { - return sourceFileName; - } - - return "Source"; -} - -function isGeneratedKnowhereFileName(value: string): boolean { - return /^document-[A-Za-z0-9_-]{16,}\.[A-Za-z0-9]+$/u.test(value); -} - function getDisplayImageCitations( message: ChatMessageView, sourceTitlesByDocumentId: Readonly>, @@ -757,12 +828,68 @@ function getDisplayImageArtifacts( assetUrl, citationId: `${message.id}:artifact:${index}`, label: getArtifactLabel(artifact, sourceTitlesByDocumentId), + ...(artifact.highlightRegions && artifact.highlightRegions.length > 0 + ? { highlightRegions: artifact.highlightRegions } + : {}), }); } return imageArtifacts; } +function HighlightedChatImage({ + assetUrl, + label, + highlightRegions, +}: { + readonly assetUrl: string; + readonly label: string; + readonly highlightRegions?: ChatArtifactView["highlightRegions"]; +}): ReactElement { + const regions = highlightRegions ?? []; + + if (regions.length === 0) { + return ( + // eslint-disable-next-line @next/next/no-img-element -- Chat image citation dimensions are not known before render. + {label} + ); + } + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- Chat image citation dimensions are not known before render. */} + {label} + +
+ ); +} + function getDisplayDerivedTableArtifacts( message: ChatMessageView, ): readonly DisplayDerivedTableArtifact[] { @@ -825,43 +952,7 @@ function getUrlPathname(assetUrl: string): string { } } -function getCitationDisplayKey( - citation: ChatCitationView, - label: string, -): string { - const documentId = getTrimmedCitationField(citation.source.documentId); - if (documentId) { - return joinCitationDisplayKeyParts([ - "document", - documentId, - getMeaningfulCitationKeyField(citation.source.sectionPath) ?? "", - getMeaningfulCitationKeyField(citation.description) ?? "", - label, - ]); - } - - return joinCitationDisplayKeyParts([ - "fallback", - getTrimmedCitationField(citation.source.sourceFileName) ?? "", - getMeaningfulCitationKeyField(citation.source.sectionPath) ?? "", - getMeaningfulCitationKeyField(citation.description) ?? "", - label, - ]); -} - -function getMeaningfulCitationKeyField(value: string | null | undefined): string | null { - const field = getTrimmedCitationField(value); - if (!field || field === "Root" || isGeneratedKnowhereFileName(field)) return null; - return field; -} - function getTrimmedCitationField(value: string | null | undefined): string | null { const trimmedValue = value?.trim() ?? ""; return trimmedValue.length > 0 ? trimmedValue : null; } - -function joinCitationDisplayKeyParts(parts: readonly string[]): string { - return parts - .map((part: string): string => `${part.length}:${part}`) - .join("|"); -} diff --git a/src/components/chat-panel.test.ts b/src/components/chat-panel.test.ts index 54ccce5..6ca70f9 100644 --- a/src/components/chat-panel.test.ts +++ b/src/components/chat-panel.test.ts @@ -254,7 +254,9 @@ describe("ChatPanel", () => { ); await user.click(screen.getByRole("button", { name: "Send message" })); - expect(onSend).toHaveBeenCalledWith("Summarize revenue"); + expect(onSend).toHaveBeenCalledWith("Summarize revenue", { + useAgentic: true, + }); expect( analyticsMocks.trackNotebookAssistantQuestionSubmitted, ).toHaveBeenCalledWith({ @@ -374,7 +376,11 @@ describe("ChatPanel", () => { await user.click(duplicatedSourceLinks[0]); - expect(onCitationClick).toHaveBeenCalledWith(firstCitation, "assistant_1:0"); + expect(onCitationClick).toHaveBeenCalledWith( + firstCitation, + "assistant_1:0", + [], + ); }); it("keeps separate source links when different documents share one displayed label", async () => { @@ -428,6 +434,7 @@ describe("ChatPanel", () => { expect(onCitationClick).toHaveBeenCalledWith( secondCitation, "assistant_1:1", + [], ); }); @@ -441,7 +448,7 @@ describe("ChatPanel", () => { { id: "assistant_1", role: "assistant", - content: "The deadline is Monday.", + content: "The deadline is Monday. [[cite:1]]", citations: [ { chunkType: "text", @@ -460,29 +467,21 @@ describe("ChatPanel", () => { }), ); - const citationButton = screen.getByRole("button", { - name: "Open source syllabus.pdf", - }); + const citationButton = screen.getByTestId("citation-chip"); expect(screen.getByText("Sources")).toBeTruthy(); expect(citationButton.getAttribute("aria-busy")).toBe("true"); expect(citationButton.textContent).toBe("syllabus.pdf"); - expect(citationButton.className).toContain("rounded-md"); - expect(citationButton.className).toContain("h-8"); + expect(citationButton.className).toContain("rounded-[4px]"); + expect(citationButton.className).toContain("h-5"); expect(citationButton.className).toContain("max-w-[250px]"); expect(citationButton.className).toContain("font-mono"); - expect(citationButton.className).toContain("text-primary"); - expect(citationButton.className).toContain("bg-primary/10"); - expect(citationButton.className).toContain("border-primary/20"); - expect(citationButton.className).toContain("hover:border-primary/35"); - expect(citationButton.className).toContain("hover:bg-primary/15"); - expect(citationButton.className).toContain("dark:bg-[#5c606b]"); - expect(citationButton.className).toContain("dark:text-[#cfd3dc]"); - expect(citationButton.className).toContain( - "hover:shadow-[0_0_0_2px_rgba(37,99,235,0.12)]", - ); - expect(citationButton.className).not.toContain("border-border"); - expect(citationButton.className).not.toContain("bg-background/80"); + expect(citationButton.className).toContain("bg-muted"); + expect(citationButton.className).toContain("text-muted-foreground"); + expect(citationButton.className).toContain("text-[11px]"); + expect(citationButton.className).not.toContain("h-8"); + expect(citationButton.className).not.toContain("bg-primary/10"); + expect(citationButton.className).not.toContain("text-primary"); expect(citationButton.className).not.toContain("underline"); await user.click(citationButton); @@ -496,7 +495,7 @@ describe("ChatPanel", () => { { id: "assistant_1", role: "assistant", - content: "The chunk title is long.", + content: "The chunk title is long. [[cite:1]]", citations: [ { chunkType: "text", @@ -514,13 +513,12 @@ describe("ChatPanel", () => { }), ); - const sourceLink = screen.getByRole("button", { - name: /Open source TSLA-Q4-2025-UPDATE\.PDF/, - }); + const sourceLink = screen.getByTestId("citation-chip"); expect(screen.getByText("Sources")).toBeTruthy(); expect(sourceLink.className).toContain("max-w-[250px]"); - expect(sourceLink.className).toContain("rounded-md"); + expect(sourceLink.className).toContain("rounded-[4px]"); + expect(sourceLink.className).toContain("bg-muted"); expect(sourceLink.className).not.toContain("underline"); expect( within(sourceLink).getByText( diff --git a/src/components/chat-panel.tsx b/src/components/chat-panel.tsx index 92d3cbf..85efa91 100644 --- a/src/components/chat-panel.tsx +++ b/src/components/chat-panel.tsx @@ -6,7 +6,10 @@ import { type ReactElement, } from "react"; import { History, Plus } from "lucide-react"; -import { ChatComposer } from "@/components/chat-composer"; +import { + ChatComposer, + type ChatSendOptions, +} from "@/components/chat-composer"; import { ChatHistorySheet } from "@/components/chat-history-sheet"; import { ChatMessageList, @@ -34,6 +37,7 @@ import { } from "@/components/ui/tooltip"; import type { ChatCitationView, + ChatImageHighlightBox, ChatMessageView, ChatThreadView, } from "@/domains/chat/types"; @@ -47,11 +51,15 @@ export type ChatPanelProps = { messages: ChatMessageView[]; threads: ChatThreadView[]; activeThreadId?: string | null; - onSend?: (text: string) => void; + onSend?: (text: string, options: ChatSendOptions) => void; onNewChat?: () => void; onThreadSelect?: (threadId: string) => void; onThreadArchive?: (threadId: string) => void; - onCitationClick?: (citation: ChatCitationView, citationId: string) => void; + onCitationClick?: ( + citation: ChatCitationView, + citationId: string, + highlightRegions?: readonly ChatImageHighlightBox[], + ) => void; onLoginClick?: () => void; sourceTitlesByDocumentId?: Readonly>; sourceCount?: number; @@ -152,7 +160,10 @@ export function ChatPanel({ } } - function handleComposerSend(text: string): void { + function handleComposerSend( + text: string, + options: ChatSendOptions, + ): void { if (isCreateDiagramCommand(text)) { void handleCreateDiagramCommand(); return; @@ -165,7 +176,7 @@ export function ChatPanel({ sourceCountSnapshot: sourceCount, messageLength: text.length, }); - onSend?.(text); + onSend?.(text, options); } return ( diff --git a/src/components/chunks-panel-state.test.ts b/src/components/chunks-panel-state.test.ts index bcee0f0..febfd71 100644 --- a/src/components/chunks-panel-state.test.ts +++ b/src/components/chunks-panel-state.test.ts @@ -55,6 +55,16 @@ describe("chunksPanelState", () => { "chunk_page_7_second", "chunk_without_page", ]) + expect( + chunksPanelState + .getChunksWithFocusedFirst(chunks, null, 7) + .map((chunk) => chunk.chunkId), + ).toEqual([ + "chunk_page_7", + "chunk_page_2", + "chunk_page_7_second", + "chunk_without_page", + ]) }) it("moves a focused Parsed Chunk to the front without mutating the input", () => { @@ -139,6 +149,184 @@ describe("chunksPanelState", () => { ]) }) + it("deduplicates singleton page-asset chunks with the same page number", () => { + const chunks: ParsedChunkView[] = [ + { + chunkId: "page_4_first", + type: "page", + content: "First page 4 summary.", + sourceTitle: "manual.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-4-a.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "page_4_duplicate", + type: "page", + content: "Duplicate page 4 summary.", + sourceTitle: "manual.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-4-b.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "page_5", + type: "page", + content: "Page 5 summary.", + sourceTitle: "manual.pdf", + pageAssets: [ + { + pageNumber: 5, + assetUrl: "https://assets.example/page-5.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "table_1", + type: "table", + content: "", + sourceTitle: "manual.pdf", + pageNums: [4], + }, + ] + + expect( + chunksPanelState + .getPageAssetChunksWithoutDuplicatePages(chunks) + .map((chunk) => chunk.chunkId), + ).toEqual(["page_4_first", "page_5"]) + }) + + it("keeps overlapping page-memory section chunks that share a boundary page", () => { + const chunks: ParsedChunkView[] = [ + { + chunkId: "kenneth", + type: "page", + content: "IR introduction.", + sectionPath: "call.pdf/Root/Kenneth Dorell", + sourceTitle: "call.pdf", + pageNums: [1], + pageAssets: [ + { + pageNumber: 1, + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "zuckerberg", + type: "page", + content: "[SAME-AS call.pdf/Root/Kenneth Dorell p1] CEO remarks.", + sectionPath: "call.pdf/Root/Mark Zuckerberg, CEO", + sourceTitle: "call.pdf", + pageNums: [1, 2], + pageAssets: [ + { + pageNumber: 1, + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + { + pageNumber: 2, + assetUrl: "https://assets.example/page-2.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "outlook", + type: "page", + content: "Q2 outlook.", + sectionPath: "call.pdf/Root/Moving to our financial outlook.", + sourceTitle: "call.pdf", + pageNums: [8], + pageAssets: [ + { + pageNumber: 8, + assetUrl: "https://assets.example/page-8.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "capex", + type: "page", + content: "[SAME-AS call.pdf/Root/Moving to our financial outlook. p8] Q&A.", + sectionPath: "call.pdf/Root/Turning to the expense and capex outlooks.", + sourceTitle: "call.pdf", + pageNums: [8, 9, 10], + pageAssets: [ + { + pageNumber: 8, + assetUrl: "https://assets.example/page-8.png", + contentType: "image/png", + }, + { + pageNumber: 9, + assetUrl: "https://assets.example/page-9.png", + contentType: "image/png", + }, + { + pageNumber: 10, + assetUrl: "https://assets.example/page-10.png", + contentType: "image/png", + }, + ], + }, + ] + + expect( + chunksPanelState + .getPageAssetChunksWithoutDuplicatePages(chunks) + .map((chunk) => chunk.chunkId), + ).toEqual(["kenneth", "zuckerberg", "outlook", "capex"]) + }) + + it("hides table asset chunks from page-asset lists", () => { + const chunks: ParsedChunkView[] = [ + { + chunkId: "page_4", + type: "page", + content: "Financial summary.", + sourceTitle: "TSLA-Q4-2025-Update.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-4.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "table_page_4_1", + type: "table", + content: "tables/table_page_4_1.html", + sourceTitle: "TSLA-Q4-2025-Update.pdf", + pageNums: [4], + filePath: "tables/table_page_4_1.html", + }, + ] + + expect( + chunksPanelState + .getPageAssetChunksWithoutDuplicatePages(chunks) + .map((chunk) => chunk.chunkId), + ).toEqual(["page_4"]) + }) + it("formats Knowhere section paths and reference labels for display", () => { expect( chunksPanelState.formatChunkSectionPath( diff --git a/src/components/chunks-panel-state.ts b/src/components/chunks-panel-state.ts index dfd2ac7..3afccbf 100644 --- a/src/components/chunks-panel-state.ts +++ b/src/components/chunks-panel-state.ts @@ -41,6 +41,10 @@ type ChunksPanelStateModule = { readonly getChunksWithFocusedFirst: ( chunks: readonly ParsedChunkView[], focusedChunkId: string | null, + focusedPageNumber?: number | null, + ) => readonly ParsedChunkView[] + readonly getPageAssetChunksWithoutDuplicatePages: ( + chunks: readonly ParsedChunkView[], ) => readonly ParsedChunkView[] readonly getReferenceLabel: (connection: ParsedChunkConnection) => string readonly getRenderableReferences: ( @@ -54,14 +58,17 @@ const knowhereSectionSegmentSeparator = /--!?>|\/+/ function getChunksWithFocusedFirst( chunks: readonly ParsedChunkView[], focusedChunkId: string | null, + focusedPageNumber: number | null = null, ): readonly ParsedChunkView[] { const orderedChunks = getChunksOrderedByPageNumber( dedupeChunksById(chunks), ) - if (!focusedChunkId) return orderedChunks + const targetChunkId = + focusedChunkId ?? findChunkIdForPageNumber(orderedChunks, focusedPageNumber) + if (!targetChunkId) return orderedChunks const focusedIndex = orderedChunks.findIndex( - (chunk) => chunk.chunkId === focusedChunkId, + (chunk) => chunk.chunkId === targetChunkId, ) if (focusedIndex <= 0) return orderedChunks @@ -73,6 +80,31 @@ function getChunksWithFocusedFirst( ] } +function findChunkIdForPageNumber( + chunks: readonly ParsedChunkView[], + pageNumber: number | null, +): string | null { + if (pageNumber === null) return null + return ( + chunks.find((chunk) => chunkMatchesPageNumber(chunk, pageNumber)) + ?.chunkId ?? null + ) +} + +function chunkMatchesPageNumber( + chunk: ParsedChunkView, + pageNumber: number, +): boolean { + if ( + (chunk.pageAssets ?? []).some( + (pageAsset) => pageAsset.pageNumber === pageNumber, + ) + ) { + return true + } + return (chunk.pageNums ?? []).includes(pageNumber) +} + function getChunksOrderedByPageNumber( chunks: readonly ParsedChunkView[], ): readonly ParsedChunkView[] { @@ -104,7 +136,7 @@ function buildSectionTree( const root = createMutableSectionTreeNode({ id: "root", kind: "root", - label: sourceTitle.trim() || "Parsed Chunks", + label: sourceTitle.trim() || "Parsed Results", }) const chunksByParserChunkId = new Map( uniqueChunks @@ -157,6 +189,47 @@ function dedupeChunksById( return uniqueChunks } +function getPageAssetChunksWithoutDuplicatePages( + chunks: readonly ParsedChunkView[], +): readonly ParsedChunkView[] { + const seenSingletonPageNumbers = new Set() + + return chunks.filter((chunk) => { + // Page-memory table assets currently store a file path, not HTML. + if (chunk.type === "table") return false + if (chunk.type !== "page") return true + + const pageNumbers = getPageAssetChunkPageNumbers(chunk) + if (pageNumbers.length === 0) return true + if (pageNumbers.length > 1) return true + + const pageNumber = pageNumbers[0]! + if (seenSingletonPageNumbers.has(pageNumber)) return false + + seenSingletonPageNumbers.add(pageNumber) + return true + }) +} + +function getPageAssetChunkPageNumbers( + chunk: ParsedChunkView, +): readonly number[] { + const pageAssetNumbers = uniquePositivePageNumbers( + (chunk.pageAssets ?? []).map((pageAsset) => pageAsset.pageNumber), + ) + if (pageAssetNumbers.length > 0) return pageAssetNumbers + + return uniquePositivePageNumbers(chunk.pageNums ?? []) +} + +function uniquePositivePageNumbers( + pageNumbers: readonly number[], +): readonly number[] { + return [...new Set(pageNumbers.filter(isPositivePageNumber))].sort( + (left, right) => left - right, + ) +} + function createMutableSectionTreeNode(input: { readonly id: string readonly kind: ChunkSectionTreeNodeKind @@ -336,6 +409,10 @@ function getFirstPageNumber(chunk: ParsedChunkView): number | null { return Math.min(...finitePageNumbers) } +function isPositivePageNumber(pageNumber: number): boolean { + return Number.isFinite(pageNumber) && pageNumber > 0 +} + function formatChunkSectionPath( sectionPath: ParsedChunkView["sectionPath"], ): string | null { @@ -454,6 +531,7 @@ export const chunksPanelState: ChunksPanelStateModule = { formatChunkSectionPath, formatReferenceLabel, getChunksWithFocusedFirst, + getPageAssetChunksWithoutDuplicatePages, getReferenceLabel, getRenderableReferences, } diff --git a/src/components/chunks-panel-workflow.test.ts b/src/components/chunks-panel-workflow.test.ts index 7f5717c..9364c44 100644 --- a/src/components/chunks-panel-workflow.test.ts +++ b/src/components/chunks-panel-workflow.test.ts @@ -87,6 +87,49 @@ describe("useChunksPanelWorkflow", () => { ]) }) + it("places the cited page card first when focusedPageNumber is set", () => { + const { result } = renderHook(() => + useChunksPanelWorkflow( + makeInput({ + chunks: [ + makeChunk({ + chunkId: "page_1", + type: "page", + pageNums: [1], + pageAssets: [ + { + pageNumber: 1, + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }), + makeChunk({ + chunkId: "page_4", + type: "page", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-4.png", + contentType: "image/png", + }, + ], + }), + ], + focusedPageNumber: 4, + focusedPageRequestId: 1, + selectedSource: "manual.pdf", + }), + ), + ) + + expect(result.current.visibleChunks.map((chunk) => chunk.chunkId)).toEqual([ + "page_4", + "page_1", + ]) + }) + it("requests another page only when the visible parsed viewport nears the bottom", () => { const onLoadMore = vi.fn() const { result } = renderHook(() => diff --git a/src/components/chunks-panel-workflow.ts b/src/components/chunks-panel-workflow.ts index 4a4b896..d9cf312 100644 --- a/src/components/chunks-panel-workflow.ts +++ b/src/components/chunks-panel-workflow.ts @@ -26,6 +26,8 @@ type ChunksPanelWorkflowInput = { readonly selectedSourceFile: SourceOriginalFileView | null readonly focusedChunkId: string | null readonly focusedChunkRequestId: number + readonly focusedPageNumber?: number | null + readonly focusedPageRequestId?: number readonly hasMoreChunks: boolean readonly isLoading: boolean readonly isLoadingMore: boolean @@ -67,6 +69,8 @@ export function useChunksPanelWorkflow({ selectedSourceFile, focusedChunkId, focusedChunkRequestId, + focusedPageNumber = null, + focusedPageRequestId = 0, hasMoreChunks, isLoading, isLoadingMore, @@ -95,8 +99,13 @@ export function useChunksPanelWorkflow({ const hasOriginalFile = selectedSource !== null && selectedSourceFile !== null const visibleView = hasOriginalView ? activeView : "parsed" const visibleChunks = useMemo( - () => chunksPanelState.getChunksWithFocusedFirst(chunks, activeFocusedChunkId), - [activeFocusedChunkId, chunks], + () => + chunksPanelState.getChunksWithFocusedFirst( + chunks, + activeFocusedChunkId, + focusedPageNumber, + ), + [activeFocusedChunkId, chunks, focusedPageNumber], ) const getVirtualChunkKey = useCallback( (index: number): string | number => visibleChunks[index]?.chunkId ?? index, @@ -149,7 +158,7 @@ export function useChunksPanelWorkflow({ ) const scrollToFocusedChunk = useCallback((): void => { - if (!activeFocusedChunkId) return + if (!activeFocusedChunkId && focusedPageNumber === null) return chunkVirtualizer.scrollToOffset(0, { align: "start", @@ -161,7 +170,7 @@ export function useChunksPanelWorkflow({ behavior: "smooth", }) }) - }, [activeFocusedChunkId, chunkVirtualizer]) + }, [activeFocusedChunkId, chunkVirtualizer, focusedPageNumber]) const measureVirtualChunkElement = useCallback( (node: HTMLDivElement | null): void => { @@ -216,12 +225,18 @@ export function useChunksPanelWorkflow({ }, [requestMoreChunksIfNeeded, totalHeight, visibleChunks.length]) useEffect(() => { - if (!activeFocusedChunkId) { + if (!activeFocusedChunkId && focusedPageNumber === null) { return } scrollToFocusedChunk() - }, [activeFocusedChunkId, activeFocusedChunkRequestId, scrollToFocusedChunk]) + }, [ + activeFocusedChunkId, + activeFocusedChunkRequestId, + focusedPageNumber, + focusedPageRequestId, + scrollToFocusedChunk, + ]) useEffect(() => { if (!hasOriginalView) setActiveView("parsed") diff --git a/src/components/chunks-panel.test.ts b/src/components/chunks-panel.test.ts index f99a68b..a56da3f 100644 --- a/src/components/chunks-panel.test.ts +++ b/src/components/chunks-panel.test.ts @@ -37,6 +37,7 @@ describe("ChunksPanel", () => { unobserve() {} disconnect() {} }; + Element.prototype.scrollIntoView = vi.fn(); }); afterEach(async () => { @@ -66,9 +67,269 @@ describe("ChunksPanel", () => { ); expect( - screen.getByRole("heading", { name: "Parsed Chunks" }), + screen.getByRole("heading", { name: "Parsed Results" }), ).toBeTruthy(); - expect(screen.getByText(/Showing all parsed chunks from/)).toBeTruthy(); + expect(screen.getByText(/From/)).toBeTruthy(); + }); + + it("renders page chunk assets inside the normal chunk list", async () => { + mockVisibleVirtualViewport(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "page_4", + type: "page", + content: "Page 4 summary", + sourceTitle: "report.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-000004.png", + contentType: "image/png", + width: 1200, + height: 1600, + }, + ], + }, + { + chunkId: "table_1", + type: "table", + content: "
Budget
", + sourceTitle: "report.pdf", + }, + ], + selectedSource: "report.pdf", + selectedSourceView: { + id: "source_1", + title: "report.pdf", + mimeType: "application/pdf", + status: "ready", + documentPresentation: { kind: "page-assets", pageCount: 4 }, + }, + }), + ); + + expect(screen.getByRole("heading", { name: "Parsed Results" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Tree" })).toBeTruthy(); + expect(screen.getByTestId("chunk-section-tree-zoom-overlay")).toBeTruthy(); + selectListView(); + expect( + await screen.findByRole("img", { name: "Page 4" }), + ).toBeTruthy(); + expect(screen.getByText("image/png")).toBeTruthy(); + expect(screen.queryByText("Budget")).toBeNull(); + expect(screen.queryByTestId("chunk-card-shell-table_1")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Original" }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: /original file/i }), + ).toBeNull(); + }); + + it("renders only the first page-asset chunk for each page number", async () => { + mockVisibleVirtualViewport(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "page_4_first", + type: "page", + content: "First page 4 summary", + sourceTitle: "report.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-000004-a.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "page_4_duplicate", + type: "page", + content: "Duplicate page 4 summary", + sourceTitle: "report.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-000004-b.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "page_5", + type: "page", + content: "Page 5 summary", + sourceTitle: "report.pdf", + pageNums: [5], + pageAssets: [ + { + pageNumber: 5, + assetUrl: "https://assets.example/page-000005.png", + contentType: "image/png", + }, + ], + }, + ], + selectedSource: "report.pdf", + selectedSourceView: { + id: "source_1", + title: "report.pdf", + mimeType: "application/pdf", + status: "ready", + documentPresentation: { kind: "page-assets", pageCount: 5 }, + }, + }), + ); + + selectListView(); + expect(await screen.findAllByRole("img", { name: "Page 4" })) + .toHaveLength(1); + expect(screen.getByRole("img", { name: "Page 5" })).toBeTruthy(); + expect(screen.queryByTestId("chunk-card-shell-page_4_duplicate")) + .toBeNull(); + }); + + it("keeps overlapping page-memory sections that share a first page image", async () => { + mockVisibleVirtualViewport(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "kenneth", + type: "page", + content: "IR introduction", + sectionPath: "call.pdf/Root/Kenneth Dorell", + sourceTitle: "call.pdf", + pageNums: [1], + pageAssets: [ + { + pageNumber: 1, + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + ], + }, + { + chunkId: "zuckerberg", + type: "page", + content: "CEO remarks", + sectionPath: "call.pdf/Root/Mark Zuckerberg, CEO", + sourceTitle: "call.pdf", + pageNums: [1, 2], + pageAssets: [ + { + pageNumber: 1, + assetUrl: "https://assets.example/page-1.png", + contentType: "image/png", + }, + { + pageNumber: 2, + assetUrl: "https://assets.example/page-2.png", + contentType: "image/png", + }, + ], + }, + ], + selectedSource: "call.pdf", + selectedSourceView: { + id: "source_1", + title: "call.pdf", + mimeType: "application/pdf", + status: "ready", + documentPresentation: { kind: "page-assets", pageCount: 2 }, + }, + }), + ); + + selectListView(); + expect(await screen.findByTestId("chunk-card-shell-kenneth")).toBeTruthy(); + expect(screen.getByTestId("chunk-card-shell-zuckerberg")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Page 2" })).toBeTruthy(); + }); + + it("renders page chunks normally when no page assets exist", async () => { + mockVisibleVirtualViewport(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "page_4", + type: "page", + content: "Page 4 summary", + readableContent: "Page 4 summary", + sourceTitle: "report.pdf", + pageNums: [4], + }, + ], + selectedSource: "report.pdf", + selectedSourceView: { + id: "source_1", + title: "report.pdf", + mimeType: "application/pdf", + status: "ready", + documentPresentation: { kind: "page-assets", pageCount: 4 }, + }, + }), + ); + + selectListView(); + expect(await screen.findByText("Page 4 summary")).toBeTruthy(); + expect(screen.queryByRole("img", { name: "Page 4" })).toBeNull(); + }); + + it("shows a page-level placeholder when a page asset image fails to load", async () => { + mockVisibleVirtualViewport(); + + render( + React.createElement(C, { + chunks: [ + { + chunkId: "page_4", + type: "page", + content: "Page 4 summary", + sourceTitle: "report.pdf", + pageNums: [4], + pageAssets: [ + { + pageNumber: 4, + assetUrl: "https://assets.example/page-000004.png", + contentType: "image/png", + width: 1200, + height: 1600, + }, + ], + }, + ], + selectedSource: "report.pdf", + selectedSourceView: { + id: "source_1", + title: "report.pdf", + mimeType: "application/pdf", + status: "ready", + documentPresentation: { kind: "page-assets", pageCount: 4 }, + }, + }), + ); + + selectListView(); + const pageImage = await screen.findByRole("img", { name: "Page 4" }); + fireEvent.error(pageImage); + + expect( + screen.getByTestId("page-asset-image-unavailable-4"), + ).toBeTruthy(); + expect(screen.getByText("Page image unavailable.")).toBeTruthy(); }); it("defaults parsed chunks into a section tree view", () => { @@ -762,7 +1023,7 @@ describe("ChunksPanel", () => { await user.click(screen.getByRole("button", { name: "Parsed" })); - expect(screen.getByRole("heading", { name: "Parsed Chunks" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Parsed Results" })).toBeTruthy(); expect(screen.getByTestId("source-original-preview")).toBe( mountedOriginalPreview, ); @@ -813,7 +1074,7 @@ describe("ChunksPanel", () => { await waitFor(() => { expect( - screen.getByRole("heading", { name: "Referenced Chunks" }), + screen.getByRole("heading", { name: "Parsed Results" }), ).toBeTruthy(); }); expect(screen.getByTestId("chunk-card-shell-chunk_1")).toBeTruthy(); diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx index 898ca39..84298bd 100644 --- a/src/components/chunks-panel.tsx +++ b/src/components/chunks-panel.tsx @@ -37,26 +37,36 @@ import { } from "@/components/ui/tooltip"; import { useChunksPanelWorkflow } from "@/components/chunks-panel-workflow"; import { ParsedChunkCard } from "@/components/parsed-chunk-card"; +import type { ChatImageHighlightBox } from "@/domains/chat/types"; import { chunksPanelState } from "@/components/chunks-panel-state"; import { MAX_UPLOAD_MB } from "@/domains/sources/validation"; import { useSourceOriginalPreviewWarmup } from "@/components/source-original-preview-warmup"; import { sourceOriginalPreviewModel } from "@/components/source-original-preview-model"; import type { ParsedChunkView } from "@/domains/chunks/types"; -import type { SourceOriginalFileView, SourceView } from "@/domains/sources/types"; +import type { + SourceOriginalFileView, + SourceView, +} from "@/domains/sources/types"; import type { AnalyticsContext } from "@/lib/posthog"; import { cn } from "@/lib/utils"; export type ChunksPanelProps = { chunks: ParsedChunkView[]; selectedSource?: string | null; + selectedSourceView?: SourceView | null; selectedSourceFile?: SourceOriginalFileView | null; focusedChunkId?: string | null; focusedChunkRequestId?: number; + focusedPageNumber?: number | null; + focusedPageRequestId?: number; + focusedCitationId?: string | null; + focusedHighlightRegions?: readonly ChatImageHighlightBox[]; citationListViewRequestId?: number; isLoading?: boolean; isLoadingMore?: boolean; isLoadingAllChunks?: boolean; hasMoreChunks?: boolean; + processingMessage?: string | null; onLoadMore?: () => void; onLoadAllChunks?: () => void; onLoginClick?: () => void; @@ -76,14 +86,20 @@ type ChunkDisplayModeState = { export function ChunksPanel({ chunks = [], selectedSource = null, + selectedSourceView = null, selectedSourceFile = null, focusedChunkId = null, focusedChunkRequestId = 0, + focusedPageNumber = null, + focusedPageRequestId = 0, + focusedCitationId = null, + focusedHighlightRegions = [], citationListViewRequestId = 0, isLoading = false, isLoadingMore = false, isLoadingAllChunks = false, hasMoreChunks = false, + processingMessage = null, onLoadMore, onLoadAllChunks, onLoginClick, @@ -91,8 +107,23 @@ export function ChunksPanel({ analyticsContext, sourceCountSnapshot = 0, }: Partial = {}) { + const hasPageAssetChunks = chunks.some( + (chunk) => (chunk.pageAssets?.length ?? 0) > 0, + ); + const isPageAssetSource = + selectedSourceView?.documentPresentation?.kind === "page-assets" || + hasPageAssetChunks; + const displayChunks = useMemo( + () => + isPageAssetSource + ? chunksPanelState.getPageAssetChunksWithoutDuplicatePages(chunks) + : chunks, + [chunks, isPageAssetSource], + ); + const effectiveVisibleView = isPageAssetSource ? "parsed" : undefined; const originalPreviewCacheKey = selectedSourceFile?.url ?? null; const isOriginalPreviewAvailable = + !isPageAssetSource && sourceOriginalPreviewModel.canPreviewOriginalFile( selectedSource, selectedSourceFile, @@ -127,16 +158,19 @@ export function ChunksPanel({ visibleChunks, visibleView, } = useChunksPanelWorkflow({ - chunks, + chunks: displayChunks, selectedSource, selectedSourceFile, focusedChunkId, focusedChunkRequestId, + focusedPageNumber, + focusedPageRequestId, hasMoreChunks, isLoading, isLoadingMore, onLoadMore, }); + const activeVisibleView = effectiveVisibleView ?? visibleView; useSourceOriginalPreviewWarmup({ sourceTitle: selectedSource, @@ -238,14 +272,18 @@ export function ChunksPanel({ citationListViewRequestId ? "list" : chunkDisplayModeState.mode; - const headerTitle = focusedChunkId ? "Referenced Chunks" : "Parsed Chunks"; + const headerTitle = activeVisibleView === "original" + ? "Original File" + : "Parsed Results"; const shouldMountOriginalPreview = - visibleView === "original" || - (originalPreviewCacheKey !== null && - mountedOriginalPreviewKey === originalPreviewCacheKey); + !isPageAssetSource && + (activeVisibleView === "original" || + (originalPreviewCacheKey !== null && + mountedOriginalPreviewKey === originalPreviewCacheKey)); const isTreeModeVisible = - visibleView === "parsed" && chunkDisplayMode === "tree"; - const headerSubtitle = visibleView === "original" ? ( + activeVisibleView === "parsed" && + chunkDisplayMode === "tree"; + const headerSubtitle = activeVisibleView === "original" ? ( selectedSource ? ( <> Showing the original file for{" "} @@ -256,17 +294,15 @@ export function ChunksPanel({ ) : ( "Select a source to preview its original file." ) - ) : focusedChunkId ? ( - <>Showing relevant chunks from the last answer. ) : selectedSource ? ( <> - Showing all parsed chunks from{" "} + From{" "} {selectedSource} ) : ( - "Select a source to see its parsed chunks." + "Select a source to see its parsed results." ); useEffect(() => { @@ -297,14 +333,15 @@ export function ChunksPanel({

- {visibleView === "original" ? "Original File" : headerTitle} + {headerTitle}

{headerSubtitle}

- {visibleView === "parsed" && chunks.length > 0 ? ( + {activeVisibleView === "parsed" && + displayChunks.length > 0 ? (
) : null} - {hasOriginalView ? ( + {!isPageAssetSource && hasOriginalView ? (
@@ -348,7 +385,7 @@ export function ChunksPanel({
- + {isLoading ? ( - - ) : chunks.length === 0 ? ( + + ) : displayChunks.length === 0 && processingMessage ? ( + + ) : displayChunks.length === 0 ? ( selectedSource ? ( ) : ( @@ -375,10 +414,10 @@ export function ChunksPanel({ ) : isTreeModeVisible ? ( {shouldMountOriginalPreview ? ( - + + +

+ {message} +

+
+ ); +} + function truncateTreeLabel(value: string): string { const maxLength = 42; if (value.length <= maxLength) return value; @@ -1138,13 +1198,19 @@ function EmptyChunks(): ReactNode { ); } -function LoadingChunks(): ReactNode { +function LoadingChunks({ + message, +}: { + readonly message?: string | null; +}): ReactNode { return (
-

Loading parsed chunks...

+

+ {message ?? "Loading parsed chunks..."} +

); } @@ -1153,6 +1219,10 @@ function VirtualChunkRow({ virtualItem, chunk, focusedChunkId, + focusedCitationId, + focusedPageNumber, + focusedPageRequestId, + highlightRegions, isOriginalPreviewAvailable, measureElement, onChunkClick, @@ -1162,6 +1232,10 @@ function VirtualChunkRow({ virtualItem: VirtualItem; chunk: ParsedChunkView | undefined; focusedChunkId: string | null; + focusedCitationId: string | null; + focusedPageNumber: number | null; + focusedPageRequestId: number; + highlightRegions: readonly ChatImageHighlightBox[]; isOriginalPreviewAvailable: boolean; measureElement: (node: HTMLDivElement | null) => void; onChunkClick?: (chunk: ParsedChunkView) => void; @@ -1190,6 +1264,10 @@ function VirtualChunkRow({