diff --git a/.env.local.example b/.env.local.example
index 097692f..525e9c9 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -17,8 +17,8 @@ KNOWHERE_BASE_URL=https://api.knowhereto.ai
# Vercel AI Gateway key; AI SDK picks it up automatically
AI_GATEWAY_API_KEY=vck_your_key_here
-# Optional override — defaults to deepseek/deepseek-v4-flash
-# CHAT_MODEL=deepseek/deepseek-v4-flash
+# Optional override — defaults to DeepSeek V4.1 Flash (native multimodal)
+# CHAT_MODEL=deepseek/deepseek-v4.1-flash
# --- Auth (server-side only) ---
#
diff --git a/src/agent-harness/answer-context.test.ts b/src/agent-harness/answer-context.test.ts
index a21e92c..76cf05d 100644
--- a/src/agent-harness/answer-context.test.ts
+++ b/src/agent-harness/answer-context.test.ts
@@ -1,13 +1,23 @@
import { describe, expect, it, vi } from "vitest"
+import { logger } from "@/lib/logger"
+
import { composeAnswerContext } from "./answer-context"
import type { EvidenceLedgerSnapshot } from "./types"
+const revenuePng = new Uint8Array([1, 2, 3])
+const marginPng = new Uint8Array([4, 5, 6])
+
describe("composeAnswerContext", () => {
- it("composes retained chunks, full table HTML, images, memory, and the original question", async () => {
+ it("composes retained chunks, full table HTML, images at placeholders, memory, and the original question", async () => {
const readTableHtml = vi.fn().mockResolvedValue(
"
",
)
+ const readImage = vi.fn(async (assetUrl: string) =>
+ assetUrl.includes("revenue.png")
+ ? { body: revenuePng, mediaType: "image/png" }
+ : { body: marginPng, mediaType: "image/png" },
+ )
const message = await composeAnswerContext({
ledger: makeLedger(),
memoryItems: [
@@ -20,11 +30,18 @@ describe("composeAnswerContext", () => {
],
userText: "Q4 的收入是多少?",
readTableHtml,
+ readImage,
})
expect(readTableHtml).toHaveBeenCalledWith(
"https://assets.example/tables/revenue.html?signature=valid",
)
+ expect(readImage).toHaveBeenCalledWith(
+ "https://assets.example/images/revenue.png?signature=valid",
+ )
+ expect(readImage).toHaveBeenCalledWith(
+ "https://assets.example/images/margin.png?signature=valid",
+ )
expect(message).toEqual({
role: "user",
content: [
@@ -38,24 +55,29 @@ describe("composeAnswerContext", () => {
"Revenue increased in Q4.",
"",
"",
- '[image ref="asset:r1:result:1:image_chunk_1"]',
+ "",
].join("\n"),
},
{
type: "image",
- image: new URL("https://assets.example/images/revenue.png?signature=valid"),
+ image: revenuePng,
+ mediaType: "image/png",
},
{
type: "text",
- text: '[image ref="asset:r1:result:1:image_chunk_2"]',
+ text: "\n\n\n",
},
{
type: "image",
- image: new URL("https://assets.example/images/margin.png?signature=valid"),
+ image: marginPng,
+ mediaType: "image/png",
},
{
type: "text",
text: [
+ "",
+ "",
+ "",
"## Fluid Memory",
"",
'[memory ref="mem:1" kind="stance"]',
@@ -69,6 +91,90 @@ describe("composeAnswerContext", () => {
})
})
+ it("drops image placeholders and warns when the image URL cannot be read", async () => {
+ const warn = vi.spyOn(logger, "warn").mockImplementation(() => {})
+ const message = await composeAnswerContext({
+ ledger: makeLedger({
+ content:
+ "Before [images/revenue.png] after [images/margin.png] end.",
+ assets: makeLedger().assets.filter((asset) => asset.type === "image"),
+ }),
+ memoryItems: [],
+ userText: "图在哪?",
+ readImage: vi.fn().mockRejectedValue(new Error("Unable to read image: 403")),
+ })
+
+ const parts = userContentParts(message)
+ const text = parts
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("")
+ expect(text).toContain("Before after end.")
+ expect(text).not.toContain("[images/revenue.png]")
+ expect(text).not.toContain("[images/margin.png]")
+ expect(parts.some((part) => part.type === "image")).toBe(false)
+ expect(warn).toHaveBeenCalledWith(
+ "chat: skipped unreachable evidence asset",
+ expect.objectContaining({
+ type: "image",
+ reason: "Unable to read image: 403",
+ }),
+ )
+ warn.mockRestore()
+ })
+
+ it("drops table placeholders and warns when the table URL cannot be read", async () => {
+ const warn = vi.spyOn(logger, "warn").mockImplementation(() => {})
+ const message = await composeAnswerContext({
+ ledger: makeLedger({
+ content: "Revenue [Table: tables/revenue.html] rose.",
+ assets: makeLedger().assets.filter((asset) => asset.type === "table"),
+ }),
+ memoryItems: [],
+ userText: "收入多少?",
+ readTableHtml: vi.fn().mockRejectedValue(new Error("Unable to read table HTML: 404")),
+ })
+
+ const text = userContentParts(message)
+ .filter((part) => part.type === "text")
+ .map((part) => part.text)
+ .join("")
+ expect(text).toContain("Revenue rose.")
+ expect(text).not.toContain("[Table: tables/revenue.html]")
+ expect(text).not.toContain("")
+ expect(warn).toHaveBeenCalledWith(
+ "chat: skipped unreachable evidence asset",
+ expect.objectContaining({
+ type: "table",
+ reason: "Unable to read table HTML: 404",
+ }),
+ )
+ warn.mockRestore()
+ })
+
+ it("throws when a connected image placeholder is missing", async () => {
+ await expect(
+ composeAnswerContext({
+ ledger: {
+ ...makeLedger(),
+ chunks: [
+ {
+ ...makeLedger().chunks[0]!,
+ content: "Revenue increased in Q4.\n[Table: tables/revenue.html]",
+ },
+ ],
+ },
+ memoryItems: [],
+ userText: "Q4 的收入是多少?",
+ readTableHtml: vi.fn().mockResolvedValue(""),
+ readImage: vi.fn().mockResolvedValue({
+ body: revenuePng,
+ mediaType: "image/png",
+ }),
+ }),
+ ).rejects.toThrow("Image placeholder")
+ })
+
it("omits knowledge and memory sections when neither search ran", async () => {
const message = await composeAnswerContext({
ledger: { ...makeLedger(), retrievalCount: 0, retainedPicks: [] },
@@ -132,14 +238,31 @@ describe("composeAnswerContext", () => {
})
})
-function makeLedger(): EvidenceLedgerSnapshot {
+function userContentParts(message: { readonly content: unknown }): readonly {
+ readonly type: string
+ readonly text?: string
+}[] {
+ if (!Array.isArray(message.content)) {
+ throw new Error("expected composed message content to be an array")
+ }
+ return message.content
+}
+
+function makeLedger(
+ overrides: {
+ readonly content?: string
+ readonly assets?: EvidenceLedgerSnapshot["assets"]
+ } = {},
+): EvidenceLedgerSnapshot {
return {
retrievalCount: 1,
chunks: [
{
ref: "r1:result:1",
kind: "result",
- content: "Revenue increased in Q4.\n[Table: tables/revenue.html]",
+ content:
+ overrides.content ??
+ "Revenue increased in Q4.\n[Table: tables/revenue.html]\n[images/revenue.png]\n[images/margin.png]",
contentPreview: "Revenue increased in Q4. [Table: tables/revenue.html]",
chunkType: "text",
score: 0.9,
@@ -150,7 +273,7 @@ function makeLedger(): EvidenceLedgerSnapshot {
},
},
],
- assets: [
+ assets: overrides.assets ?? [
{
ref: "asset:r1:result:1:table_chunk",
chunkRef: "r1:result:1",
diff --git a/src/agent-harness/answer-context.ts b/src/agent-harness/answer-context.ts
index 60fea5c..d99f9d8 100644
--- a/src/agent-harness/answer-context.ts
+++ b/src/agent-harness/answer-context.ts
@@ -1,5 +1,8 @@
import type { ModelMessage } from "ai"
+import { summarizeUnknownError } from "@/lib/format-log-value"
+import { logger } from "@/lib/logger"
+
import type {
EvidenceAsset,
EvidenceChunk,
@@ -8,10 +11,19 @@ import type {
} from "./types"
export type ReadTableHtml = (assetUrl: string) => Promise
+export type ReadImage = (assetUrl: string) => Promise<{
+ readonly body: Uint8Array
+ readonly mediaType: string
+}>
type UserContentParts = Exclude<
Extract["content"],
string
>
+type ImagePart = {
+ readonly type: "image"
+ readonly image: Uint8Array
+ readonly mediaType: string
+}
export async function composeAnswerContext(input: {
readonly ledger: EvidenceLedgerSnapshot
@@ -19,6 +31,7 @@ export async function composeAnswerContext(input: {
readonly memorySearchAttempted?: boolean
readonly userText: string
readonly readTableHtml?: ReadTableHtml
+ readonly readImage?: ReadImage
}): Promise {
const content: UserContentParts = []
const retainedPicks = new Set(input.ledger.retainedPicks)
@@ -49,27 +62,13 @@ export async function composeAnswerContext(input: {
})
}
- appendText(
+ await appendChunkEvidence({
content,
- [
- formatChunkLabel(pick, chunk),
- formatTableArtifactRefs(assets),
- chunkText.trim(),
- ]
- .filter((part) => part.length > 0)
- .join("\n"),
- )
-
- for (const image of assets.filter((asset) => asset.type === "image")) {
- if (!image.assetUrl) {
- throw new Error(`Retained image asset ${image.ref} has no signed URL.`)
- }
- appendText(content, `[image ref="${image.ref}"]`)
- content.push({
- type: "image",
- image: new URL(image.assetUrl),
- })
- }
+ pick,
+ chunk: { ...chunk, content: chunkText },
+ assets,
+ readImage: input.readImage,
+ })
}
}
@@ -93,6 +92,120 @@ export async function composeAnswerContext(input: {
return { role: "user", content }
}
+async function appendChunkEvidence(input: {
+ readonly content: UserContentParts
+ readonly pick: number
+ readonly chunk: EvidenceChunk
+ readonly assets: readonly EvidenceAsset[]
+ readonly readImage?: ReadImage
+}): Promise {
+ const images = input.assets.filter((asset) => asset.type === "image")
+ const prefix = [
+ formatChunkLabel(input.pick, input.chunk),
+ formatTableArtifactRefs(input.assets),
+ ]
+ .filter((part) => part.length > 0)
+ .join("\n")
+
+ if (images.length === 0) {
+ appendText(
+ input.content,
+ [prefix, input.chunk.content.trim()].filter((part) => part.length > 0).join("\n"),
+ )
+ return
+ }
+
+ if (prefix) appendRawText(input.content, `\n\n${prefix}\n`)
+
+ for (const segment of splitChunkTextAtImages(input.chunk, images)) {
+ if (segment.type === "text") {
+ appendRawText(input.content, segment.text)
+ continue
+ }
+
+ const image = await tryReadImage(segment.asset, input.readImage)
+ if (!image) continue
+
+ appendRawText(input.content, "\n")
+ input.content.push(toImagePart(image))
+ appendRawText(input.content, "\n")
+ }
+}
+
+function splitChunkTextAtImages(
+ chunk: EvidenceChunk,
+ images: readonly EvidenceAsset[],
+): Array<
+ | { readonly type: "text"; readonly text: string }
+ | { readonly type: "image"; readonly asset: EvidenceAsset }
+> {
+ if (!chunk.content.trim()) {
+ return images.map((asset) => ({ type: "image" as const, asset }))
+ }
+
+ let remaining = chunk.content
+ const unused = [...images]
+ const segments: Array<
+ | { readonly type: "text"; readonly text: string }
+ | { readonly type: "image"; readonly asset: EvidenceAsset }
+ > = []
+
+ while (unused.length > 0) {
+ const match = findEarliestImagePlaceholder(remaining, chunk, unused)
+ if (!match) {
+ throw new Error(
+ `Image placeholder for ${unused[0]?.ref} was not found in its chunk content.`,
+ )
+ }
+ if (match.before.length > 0) {
+ segments.push({ type: "text", text: match.before })
+ }
+ segments.push({ type: "image", asset: match.asset })
+ remaining = match.after
+ unused.splice(unused.indexOf(match.asset), 1)
+ }
+
+ if (remaining.length > 0) {
+ segments.push({ type: "text", text: remaining })
+ }
+ return segments
+}
+
+function findEarliestImagePlaceholder(
+ text: string,
+ chunk: EvidenceChunk,
+ images: readonly EvidenceAsset[],
+): {
+ readonly asset: EvidenceAsset
+ readonly before: string
+ readonly after: string
+} | null {
+ let best: {
+ readonly asset: EvidenceAsset
+ readonly index: number
+ readonly length: number
+ } | null = null
+
+ for (const asset of images) {
+ const match = findFirstPlaceholder(text, chunk, asset, "image")
+ if (!match) continue
+ if (
+ !best ||
+ match.index < best.index ||
+ (match.index === best.index && match.length > best.length)
+ ) {
+ best = { asset, ...match }
+ }
+ }
+
+ if (!best) return null
+ return {
+ asset: best.asset,
+ before: text.slice(0, best.index),
+ after: text.slice(best.index + best.length),
+ }
+}
+
function groupAssetsByChunkRef(
assets: readonly EvidenceAsset[],
): Map {
@@ -126,6 +239,27 @@ function appendText(
content.push({ type: "text", text: trimmed })
}
+function appendRawText(content: UserContentParts, text: string): void {
+ if (text.length === 0) return
+ const previous = content.at(-1)
+ if (previous?.type === "text") {
+ previous.text += text
+ return
+ }
+ content.push({ type: "text", text })
+}
+
+function toImagePart(image: {
+ readonly body: Uint8Array
+ readonly mediaType: string
+}): ImagePart {
+ return {
+ type: "image",
+ image: image.body,
+ mediaType: image.mediaType.split(";")[0]?.trim() || "application/octet-stream",
+ }
+}
+
function formatChunkLabel(pick: number, chunk: EvidenceChunk): string {
const location = [chunk.source.sourceFileName, chunk.source.sectionPath]
.map((part) => part?.trim())
@@ -148,31 +282,16 @@ async function replaceTableWithHtml(input: {
readonly asset: EvidenceAsset
readonly readTableHtml?: ReadTableHtml
}): Promise {
- if (!input.asset.assetUrl) {
- throw new Error(`Retained table asset ${input.asset.ref} has no signed URL.`)
- }
- if (!input.readTableHtml) {
- throw new Error("No table HTML reader was provided for retained table evidence.")
- }
-
- const html = await input.readTableHtml(input.asset.assetUrl)
+ const html = await tryReadTableHtml(input.asset, input.readTableHtml)
const content = input.chunk.content
- if (!content.trim()) return html
+ if (!content.trim()) return html ?? ""
- const placeholders = getTablePlaceholderCandidates(input.chunk, input.asset)
- const placeholderPattern = placeholders
- .map(escapeRegExp)
- .sort((left, right) => right.length - left.length)
- .map((placeholder) =>
- `\\[(?:Table\\s*:\\s*)?${placeholder}\\]|${placeholder}`,
- )
- .join("|")
- const replaced = content.replace(
- new RegExp(placeholderPattern, "gi"),
- () => html,
+ const pattern = placeholderPattern(
+ getAssetPlaceholderCandidates(input.chunk, input.asset),
+ "table",
)
-
- if (replaced === content) {
+ const replaced = content.replace(new RegExp(pattern, "gi"), () => html ?? "")
+ if (html !== null && replaced === content) {
throw new Error(
`Table placeholder for ${input.asset.ref} was not found in its chunk content.`,
)
@@ -180,7 +299,68 @@ async function replaceTableWithHtml(input: {
return replaced
}
-function getTablePlaceholderCandidates(
+async function tryReadTableHtml(
+ asset: EvidenceAsset,
+ readTableHtml?: ReadTableHtml,
+): Promise {
+ if (!asset.assetUrl) {
+ warnUnreachableAsset(asset, "missing signed URL")
+ return null
+ }
+ if (!readTableHtml) {
+ throw new Error("No table HTML reader was provided for retained table evidence.")
+ }
+ try {
+ return await readTableHtml(asset.assetUrl)
+ } catch (error) {
+ warnUnreachableAsset(asset, summarizeUnknownError(error))
+ return null
+ }
+}
+
+async function tryReadImage(
+ asset: EvidenceAsset,
+ readImage?: ReadImage,
+): Promise<{ readonly body: Uint8Array; readonly mediaType: string } | null> {
+ if (!asset.assetUrl) {
+ warnUnreachableAsset(asset, "missing signed URL")
+ return null
+ }
+ if (!readImage) {
+ throw new Error("No image reader was provided for retained image evidence.")
+ }
+ try {
+ return await readImage(asset.assetUrl)
+ } catch (error) {
+ warnUnreachableAsset(asset, summarizeUnknownError(error))
+ return null
+ }
+}
+
+function warnUnreachableAsset(asset: EvidenceAsset, reason: string): void {
+ logger.warn("chat: skipped unreachable evidence asset", {
+ type: asset.type,
+ ref: asset.ref,
+ assetUrl: asset.assetUrl ?? null,
+ sourcePath: asset.sourcePath ?? null,
+ reason,
+ })
+}
+
+function findFirstPlaceholder(
+ text: string,
+ chunk: EvidenceChunk,
+ asset: EvidenceAsset,
+ kind: "table" | "image",
+): { readonly index: number; readonly length: number } | null {
+ const pattern = placeholderPattern(getAssetPlaceholderCandidates(chunk, asset), kind)
+ if (!pattern) return null
+ const match = new RegExp(pattern, "gi").exec(text)
+ if (!match) return null
+ return { index: match.index, length: match[0].length }
+}
+
+function getAssetPlaceholderCandidates(
chunk: EvidenceChunk,
asset: EvidenceAsset,
): string[] {
@@ -199,6 +379,20 @@ function getTablePlaceholderCandidates(
return unique
}
+function placeholderPattern(
+ candidates: readonly string[],
+ kind: "table" | "image",
+): string {
+ const label = kind === "table" ? "Table" : "Image"
+ return candidates
+ .map(escapeRegExp)
+ .sort((left, right) => right.length - left.length)
+ .map((placeholder) =>
+ `\\[(?:${label}\\s*:\\s*)?${placeholder}\\]|${placeholder}`,
+ )
+ .join("|")
+}
+
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
diff --git a/src/agent-harness/runtime.test.ts b/src/agent-harness/runtime.test.ts
index 7d4c714..60f3d4a 100644
--- a/src/agent-harness/runtime.test.ts
+++ b/src/agent-harness/runtime.test.ts
@@ -117,6 +117,11 @@ describe("agent harness runtime", () => {
const readTableHtml = vi
.fn()
.mockResolvedValue("")
+ const comparisonJpg = new Uint8Array([9, 8, 7])
+ const readImage = vi.fn().mockResolvedValue({
+ body: comparisonJpg,
+ mediaType: "image/jpeg",
+ })
const result = await runAgentHarness({
model,
@@ -127,6 +132,7 @@ describe("agent harness runtime", () => {
memoryTools: makeMemoryTools(),
resolveConnectedAssets,
readTableHtml,
+ readImage,
})
expect(model.doGenerateCalls).toHaveLength(5)
@@ -137,6 +143,7 @@ describe("agent harness runtime", () => {
expect(readTableHtml).toHaveBeenCalledWith(
"https://assets.example/table_chunk",
)
+ expect(readImage).toHaveBeenCalledWith("https://assets.example/image_chunk")
expect(JSON.stringify(model.doGenerateCalls[4]?.prompt)).toContain(
"",
)
@@ -1208,7 +1215,7 @@ function makeConnectedRetrievalResponse(): RetrievalQueryResponse {
results: [
{
chunkId: "text_chunk",
- content: "Comparison [tables/comparison.html]",
+ content: "Comparison [tables/comparison.html] [images/comparison.jpg]",
chunkType: "text",
score: 0.9,
metadata: {
diff --git a/src/agent-harness/runtime.ts b/src/agent-harness/runtime.ts
index 4e69c07..e370ae9 100644
--- a/src/agent-harness/runtime.ts
+++ b/src/agent-harness/runtime.ts
@@ -9,7 +9,11 @@ import { z } from "zod"
import { logger } from "@/lib/logger"
-import { composeAnswerContext, type ReadTableHtml } from "./answer-context"
+import {
+ composeAnswerContext,
+ type ReadImage,
+ type ReadTableHtml,
+} from "./answer-context"
import { createEvidenceLedger } from "./ledger"
import { knowhereToolText } from "./knowhere-text"
import { memoryToolText } from "./memory-text"
@@ -49,6 +53,7 @@ export type RunAgentHarnessInput = {
readonly memoryTools: MemoryToolRuntime
readonly resolveConnectedAssets?: ResolveConnectedAssets
readonly readTableHtml?: ReadTableHtml
+ readonly readImage?: ReadImage
readonly maxSteps?: number
}
@@ -224,6 +229,7 @@ export async function runAgentHarness(
memorySearchAttempted: state.memorySearchAttempted === true,
userText: input.turn.userText,
readTableHtml: input.readTableHtml,
+ readImage: input.readImage,
})
}
return prepareHarnessStep({
diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts
index 784e3de..68e233e 100644
--- a/src/domains/chat/contracts.ts
+++ b/src/domains/chat/contracts.ts
@@ -9,6 +9,7 @@ import type {
HarnessRunResult,
KnowhereToolRuntime,
KnowhereSearchRequest,
+ ReadImage,
ReadTableHtml,
ResolveConnectedAssets,
} from "@/agent-harness"
@@ -63,6 +64,7 @@ export type GenerateAnswer = (input: {
knowhereTools?: KnowhereToolRuntime
resolveConnectedAssets?: ResolveConnectedAssets
readTableHtml?: ReadTableHtml
+ readImage?: ReadImage
}) => Promise
export type AnswerQuestionInput = {
@@ -80,6 +82,7 @@ export type AnswerQuestionInput = {
hardenMediaAssetUrls?: HardenMediaAssetUrls
resolveConnectedAssets?: ResolveConnectedAssets
readTableHtml?: ReadTableHtml
+ readImage?: ReadImage
messages: readonly ChatHistoryMessage[]
}
diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts
index 58ef064..a9ff504 100644
--- a/src/domains/chat/index.ts
+++ b/src/domains/chat/index.ts
@@ -263,6 +263,7 @@ export const answerQuestionWithRetrieval = (
? { resolveConnectedAssets: input.resolveConnectedAssets }
: {}),
...(input.readTableHtml ? { readTableHtml: input.readTableHtml } : {}),
+ ...(input.readImage ? { readImage: input.readImage } : {}),
}),
)
diff --git a/src/domains/chat/prompt.ts b/src/domains/chat/prompt.ts
index fa2f733..e501899 100644
--- a/src/domains/chat/prompt.ts
+++ b/src/domains/chat/prompt.ts
@@ -10,6 +10,7 @@ import {
type AgentTurnInput,
type HarnessRunResult,
type KnowhereToolRuntime,
+ type ReadImage,
type ReadTableHtml,
type ResolveConnectedAssets,
} from "@/agent-harness"
@@ -37,6 +38,7 @@ type GenerateAgenticOutputManifestInput = {
knowhereTools?: KnowhereToolRuntime
resolveConnectedAssets?: ResolveConnectedAssets
readTableHtml?: ReadTableHtml
+ readImage?: ReadImage
folderScopeSourceIds?: readonly string[]
}
@@ -79,6 +81,7 @@ export const generateAgenticOutputManifestEffect = (
? { resolveConnectedAssets: input.resolveConnectedAssets }
: {}),
...(input.readTableHtml ? { readTableHtml: input.readTableHtml } : {}),
+ ...(input.readImage ? { readImage: input.readImage } : {}),
}),
)
diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts
index 5f76eb9..7d64ed3 100644
--- a/src/domains/chat/route-answer.ts
+++ b/src/domains/chat/route-answer.ts
@@ -92,6 +92,7 @@ const answerChatEffect = (input: AnswerChatInput) =>
})
const readTableHtml = (assetUrl: string): Promise =>
readSignedTableHtml(assetUrl)
+ const readImage = (assetUrl: string) => readSignedImage(assetUrl)
const resolveConnectedAssets = createConnectedAssetResolver(
knowhereResources.client.documents,
)
@@ -121,6 +122,7 @@ const answerChatEffect = (input: AnswerChatInput) =>
}),
resolveConnectedAssets,
readTableHtml,
+ readImage,
repository: chatTurnPersistence.createRepository(),
}),
).pipe(
@@ -186,6 +188,20 @@ async function readSignedTableHtml(assetUrl: string): Promise {
return response.text()
}
+async function readSignedImage(assetUrl: string): Promise<{
+ readonly body: Uint8Array
+ readonly mediaType: string
+}> {
+ const fetched = await fetchChatAsset({
+ assetUrl,
+ fallbackContentType: inferContentTypeFromPath(assetUrl),
+ })
+ return {
+ body: fetched.body,
+ mediaType: fetched.contentType,
+ }
+}
+
async function hardenSingleChatAsset(input: {
readonly workspaceId: string
readonly parsedStorage: BlobParsedDocumentStorage
diff --git a/src/domains/chat/route-service.test.ts b/src/domains/chat/route-service.test.ts
index 13308a7..0ca511c 100644
--- a/src/domains/chat/route-service.test.ts
+++ b/src/domains/chat/route-service.test.ts
@@ -204,6 +204,7 @@ describe("chat route services", () => {
hardenChatAssetUrl: expect.any(Function),
resolveConnectedAssets: expect.any(Function),
readTableHtml: expect.any(Function),
+ readImage: expect.any(Function),
repository: expect.objectContaining({
appendMessageToThread: expect.any(Function),
ensureDefaultChatThread: expect.any(Function),
@@ -250,6 +251,53 @@ describe("chat route services", () => {
expect(fetch).toHaveBeenCalledWith(signedUrl)
})
+ it("injects a reader that returns signed image bytes", async () => {
+ const workspace = makeWorkspace()
+ const client = { retrieval: { query: vi.fn() } }
+ const signedUrl = "https://knowhere-storage.example/images/chart.png?signature=valid"
+ const imageBytes = new Uint8Array([137, 80, 78, 71])
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(imageBytes, {
+ headers: { "content-type": "image/png" },
+ })),
+ )
+ mocks.getAuthenticatedWithClient.mockResolvedValue({
+ user: { id: "user_1" },
+ workspace,
+ apiKey: "jwt_123",
+ client,
+ })
+ mocks.listSourcesForWorkspace.mockResolvedValue([makeSource()])
+ mocks.handleChatTurn.mockImplementation(
+ async (input: {
+ readonly readImage?: (assetUrl: string) => Promise<{
+ readonly body: Uint8Array
+ readonly mediaType: string
+ }>
+ }) => {
+ expect(await input.readImage?.(signedUrl)).toEqual({
+ body: imageBytes,
+ mediaType: "image/png",
+ })
+ return Either.right({
+ threadId: "thread_1",
+ messages: [
+ { id: "message_user", role: "user", content: "Read the chart" },
+ { id: "message_assistant", role: "assistant", content: "Answer" },
+ ],
+ })
+ },
+ )
+
+ const result = await chatAnswerRouteService.answerChat({
+ body: { message: "Read the chart" },
+ })
+
+ expect(result.status).toBe(200)
+ expect(fetch).toHaveBeenCalledWith(signedUrl)
+ })
+
it("injects a resolver that matches connected assets by parser chunk id", async () => {
const workspace = makeWorkspace()
const client = { retrieval: { query: vi.fn() } }
diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts
index d300d0c..863969a 100644
--- a/src/domains/chat/service.ts
+++ b/src/domains/chat/service.ts
@@ -80,6 +80,7 @@ type ChatTurnInput = {
hardenMediaAssetUrls?: AnswerQuestionInput["hardenMediaAssetUrls"]
resolveConnectedAssets?: AnswerQuestionInput["resolveConnectedAssets"]
readTableHtml?: AnswerQuestionInput["readTableHtml"]
+ readImage?: AnswerQuestionInput["readImage"]
repository: ChatRepository
}
@@ -148,6 +149,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) =>
? { resolveConnectedAssets: input.resolveConnectedAssets }
: {}),
readTableHtml: input.readTableHtml,
+ readImage: input.readImage,
messages: chatHistoryMessages,
}).pipe(Effect.catchAllCause(Effect.die))