@@ -331,6 +349,54 @@ function MessageBubble({
);
}
+function DerivedTableArtifactView({
+ artifact,
+}: {
+ readonly artifact: DisplayDerivedTableArtifact;
+}): ReactElement {
+ return (
+
+
+ {artifact.title}
+
+
+
+
+
+ {artifact.columns.map((column, index) => (
+ |
+ {column}
+ |
+ ))}
+
+
+
+ {artifact.rows.map((row, rowIndex) => (
+
+ {artifact.columns.map((_, columnIndex) => (
+ |
+ {row[columnIndex] ?? ""}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
+
function AssistantMessageContent({
content,
}: {
@@ -423,6 +489,26 @@ function getDisplayImageArtifacts(
return imageArtifacts;
}
+function getDisplayDerivedTableArtifacts(
+ message: ChatMessageView,
+): readonly DisplayDerivedTableArtifact[] {
+ const tables: DisplayDerivedTableArtifact[] = [];
+
+ for (const [index, artifact] of (message.artifacts ?? []).entries()) {
+ if (artifact.display === false || artifact.type !== "derived_table") continue;
+ if (!artifact.title || !artifact.columns || !artifact.rows) continue;
+
+ tables.push({
+ artifactId: `${message.id}:derived-table:${index}`,
+ title: artifact.title,
+ columns: artifact.columns,
+ rows: artifact.rows,
+ });
+ }
+
+ return tables;
+}
+
function getArtifactLabel(
artifact: ChatArtifactView,
sourceTitlesByDocumentId: Readonly>,
diff --git a/src/domains/chat/chat-citation-persistence.ts b/src/domains/chat/chat-citation-persistence.ts
index 1b160f0..bf2a1a1 100644
--- a/src/domains/chat/chat-citation-persistence.ts
+++ b/src/domains/chat/chat-citation-persistence.ts
@@ -34,7 +34,8 @@ function normalizeCitations(
function normalizeArtifacts(
artifacts: readonly ChatArtifactView[] | null | undefined,
): ChatArtifactView[] | null {
- if (!artifacts || artifacts.length === 0) return null
+ if (!artifacts) return null
+ if (artifacts.length === 0) return []
return artifacts.map(toArtifactView)
}
@@ -42,6 +43,10 @@ function toArtifactView(artifact: ChatArtifactView): ChatArtifactView {
return {
type: artifact.type,
ref: artifact.ref,
+ title: artifact.title,
+ columns: artifact.columns,
+ rows: artifact.rows,
+ sourceRefs: artifact.sourceRefs,
assetUrl: artifact.assetUrl,
label: artifact.label,
display: artifact.display,
diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts
index 0640de2..e5066e7 100644
--- a/src/domains/chat/index.test.ts
+++ b/src/domains/chat/index.test.ts
@@ -86,6 +86,7 @@ describe("answerQuestionWithRetrieval", () => {
expect(answer).toEqual({
answer: "The answer is grounded.",
citations: [result],
+ artifacts: [],
});
});
@@ -443,7 +444,53 @@ describe("answerQuestionWithRetrieval", () => {
stopReasons: [],
failureReasons: [],
decisionTraces: [],
- chunks: [],
+ chunks: [
+ {
+ ref: "r1:result:1",
+ kind: "result",
+ content: "",
+ contentPreview: "",
+ chunkType: "image",
+ score: 0.9,
+ assetUrl: frontAssetUrl,
+ assetRef: "asset:r1:result:1",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "身份证正面",
+ },
+ },
+ {
+ ref: "r1:result:2",
+ kind: "result",
+ content: "",
+ contentPreview: "",
+ chunkType: "image",
+ score: 0.88,
+ assetUrl: backAssetUrl,
+ assetRef: "asset:r1:result:2",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "身份证反面",
+ },
+ },
+ {
+ ref: "r1:result:3",
+ kind: "result",
+ content: "",
+ contentPreview: "",
+ chunkType: "image",
+ score: 0.7,
+ assetUrl: extraAssetUrl,
+ assetRef: "asset:r1:result:3",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "营业执照",
+ },
+ },
+ ],
assets: [
{
ref: "asset:r1:result:1",
@@ -498,6 +545,9 @@ describe("answerQuestionWithRetrieval", () => {
reason: "The current turn is self-contained.",
activePriorTurnIds: [],
},
+ finalized: true,
+ priorTurnReads: [],
+ toolCalls: [],
},
};
return harnessResult;
@@ -538,6 +588,300 @@ describe("answerQuestionWithRetrieval", () => {
},
],
);
+ expect(answer.citations.map((citation) => citation.assetUrl)).toEqual([
+ frontAssetUrl,
+ backAssetUrl,
+ ]);
+ });
+
+ it("returns a safe fallback when the harness still has validation errors", async () => {
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [makeRetrievalResult()],
+ evidenceText: "Grounding content",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "What changed?",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "What changed?" });
+ return {
+ ...makeHarnessRunResult("This invalid answer should not ship."),
+ trace: {
+ ...makeHarnessRunResult("").trace,
+ finalized: false,
+ validationErrors: [
+ "Agent must call finalize to produce the output manifest.",
+ ],
+ },
+ };
+ });
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "What changed?",
+ namespace: "notebook-workspace",
+ sources: [makeSource()],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(answer).toEqual({
+ answer:
+ "I couldn't safely finish that response because the agent output did not pass Notebook's validation checks. Please try again.",
+ citations: [],
+ artifacts: [],
+ });
+ });
+
+ it("keeps image-only harness output instead of treating it as no results", async () => {
+ const assetUrl = "https://blob.example/images/diagram.png";
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [
+ makeRetrievalResult({
+ content: "",
+ chunkType: "image",
+ assetUrl,
+ source: {
+ documentId: "doc_diagram",
+ sourceFileName: "generated.pdf",
+ sectionPath: "Diagram",
+ },
+ }),
+ ],
+ evidenceText: "Diagram candidate.",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "diagram",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "diagram", targetContent: "image" });
+ return {
+ manifest: {
+ text: "",
+ citations: [],
+ artifacts: [
+ {
+ type: "image",
+ ref: "asset:r1:result:1",
+ display: true,
+ reason: "Requested diagram",
+ },
+ ],
+ unresolved: [],
+ },
+ trace: {
+ ...makeHarnessRunResult("").trace,
+ finalized: true,
+ priorTurnReads: [],
+ toolCalls: [],
+ ledger: {
+ retrievalCount: 1,
+ evidenceText: ["Diagram candidate."],
+ stopReasons: [],
+ failureReasons: [],
+ decisionTraces: [],
+ chunks: [
+ {
+ ref: "r1:result:1",
+ kind: "result",
+ content: "",
+ contentPreview: "",
+ chunkType: "image",
+ score: 0.9,
+ assetUrl,
+ assetRef: "asset:r1:result:1",
+ source: {
+ documentId: "doc_diagram",
+ sourceFileName: "generated.pdf",
+ sectionPath: "Diagram",
+ },
+ },
+ ],
+ assets: [
+ {
+ ref: "asset:r1:result:1",
+ chunkRef: "r1:result:1",
+ type: "image",
+ assetUrl,
+ label: "generated.pdf / Diagram / image",
+ source: {
+ documentId: "doc_diagram",
+ sourceFileName: "generated.pdf",
+ sectionPath: "Diagram",
+ },
+ },
+ ],
+ },
+ },
+ } satisfies HarnessRunResult;
+ });
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "Show me the diagram.",
+ namespace: "notebook-workspace",
+ sources: [
+ makeSource({ title: "diagram.pdf", knowhereDocumentId: "doc_diagram" }),
+ ],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(answer.answer).not.toBe("I couldn't find that in your sources.");
+ expect(answer.artifacts?.map((artifact) => artifact.assetUrl)).toEqual([
+ assetUrl,
+ ]);
+ expect(answer.citations.map((citation) => citation.assetUrl)).toEqual([
+ assetUrl,
+ ]);
+ });
+
+ it("returns source-backed derived table artifacts from the harness manifest", async () => {
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [
+ makeRetrievalResult({
+ content: "Plan A costs $10M and takes 6 months.",
+ source: {
+ documentId: "doc_plan_a",
+ sourceFileName: "plan-a.pdf",
+ sectionPath: "Cost",
+ },
+ }),
+ makeRetrievalResult({
+ content: "Plan B costs $8M and takes 9 months.",
+ source: {
+ documentId: "doc_plan_b",
+ sourceFileName: "plan-b.pdf",
+ sectionPath: "Cost",
+ },
+ }),
+ ],
+ evidenceText: "Plan comparison evidence.",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "compare plan costs timelines",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({ query: "compare plan costs timelines" });
+ return {
+ manifest: {
+ text: "I organized the comparison into a table.",
+ citations: [],
+ artifacts: [
+ {
+ type: "derived_table",
+ ref: "derived:table:plans",
+ title: "Plan comparison",
+ columns: ["Plan", "Cost", "Timeline"],
+ rows: [
+ ["Plan A", "$10M", "6 months"],
+ ["Plan B", "$8M", "9 months"],
+ ],
+ sourceRefs: ["r1:result:1", "r1:result:2"],
+ display: true,
+ reason: "The user asked for a comparison table.",
+ },
+ ],
+ unresolved: [],
+ },
+ trace: {
+ ...makeHarnessRunResult("").trace,
+ finalized: true,
+ ledger: {
+ retrievalCount: 1,
+ evidenceText: ["Plan comparison evidence."],
+ stopReasons: [],
+ failureReasons: [],
+ decisionTraces: [],
+ chunks: [
+ {
+ ref: "r1:result:1",
+ kind: "result",
+ content: "Plan A costs $10M and takes 6 months.",
+ contentPreview: "Plan A costs $10M and takes 6 months.",
+ chunkType: "text",
+ score: 0.9,
+ source: {
+ documentId: "doc_plan_a",
+ sourceFileName: "plan-a.pdf",
+ sectionPath: "Cost",
+ },
+ },
+ {
+ ref: "r1:result:2",
+ kind: "result",
+ content: "Plan B costs $8M and takes 9 months.",
+ contentPreview: "Plan B costs $8M and takes 9 months.",
+ chunkType: "text",
+ score: 0.88,
+ source: {
+ documentId: "doc_plan_b",
+ sourceFileName: "plan-b.pdf",
+ sectionPath: "Cost",
+ },
+ },
+ ],
+ assets: [],
+ },
+ },
+ } satisfies HarnessRunResult;
+ });
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "Compare the plans in a table.",
+ namespace: "notebook-workspace",
+ sources: [
+ makeSource({ title: "Plan A.pdf", knowhereDocumentId: "doc_plan_a" }),
+ makeSource({
+ id: "source_plan_b",
+ title: "Plan B.pdf",
+ knowhereDocumentId: "doc_plan_b",
+ }),
+ ],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ messages: [],
+ }),
+ );
+
+ expect(answer.artifacts).toEqual([
+ {
+ type: "derived_table",
+ ref: "derived:table:plans",
+ title: "Plan comparison",
+ columns: ["Plan", "Cost", "Timeline"],
+ rows: [
+ ["Plan A", "$10M", "6 months"],
+ ["Plan B", "$8M", "9 months"],
+ ],
+ sourceRefs: ["r1:result:1", "r1:result:2"],
+ display: true,
+ reason: "The user asked for a comparison table.",
+ },
+ ]);
+ expect(answer.citations.map((citation) => citation.source.sourceFileName)).toEqual(
+ ["Plan A.pdf", "Plan B.pdf"],
+ );
});
it("turns retrieved evidence image filenames into image citations", async () => {
@@ -654,6 +998,7 @@ describe("answerQuestionWithRetrieval", () => {
expect(answer).toEqual({
answer: "I couldn't find that in your sources.",
citations: [],
+ artifacts: [],
});
});
@@ -1158,6 +1503,9 @@ function makeHarnessRunResult(text: string): HarnessRunResult {
failureReasons: [],
decisionTraces: [],
},
+ finalized: true,
+ priorTurnReads: [],
+ toolCalls: [],
validationErrors: [],
revisionsUsed: 0,
},
diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts
index 7c83b62..d9070f7 100644
--- a/src/domains/chat/index.ts
+++ b/src/domains/chat/index.ts
@@ -11,6 +11,7 @@ import type {
ChatCitationView,
} from "@/domains/chat/types"
import type {
+ DerivedTableArtifact,
EvidenceAsset,
EvidenceChunk,
HarnessRunResult,
@@ -44,6 +45,8 @@ const MAX_CITATION_RESULTS = 20
const KNOWHERE_RESPONSE_TEXT_LOG_LIMIT = 200
const KNOWHERE_CHUNK_LOG_LIMIT = 100
const NO_RESULTS_ANSWER = "I couldn't find that in your sources."
+const HARNESS_VALIDATION_FAILURE_ANSWER =
+ "I couldn't safely finish that response because the agent output did not pass Notebook's validation checks. Please try again."
const RAW_URL_PATTERN = /https?:\/\/[^\s)\]}>"']+/g
const REDACTED_MEDIA_URL = "[media asset URL hidden]"
const RETRIEVAL_TARGET_CONTENT_DATA_TYPES: Readonly<
@@ -183,14 +186,36 @@ export const answerQuestionWithRetrieval = (
harnessValidationErrorCount: generatedAnswer.trace.validationErrors.length,
revisionsUsed: generatedAnswer.trace.revisionsUsed,
})
+ if (generatedAnswer.trace.validationErrors.length > 0) {
+ logger.warn("chat-agent: validation failed; returning safe fallback", {
+ validationErrors: generatedAnswer.trace.validationErrors,
+ revisionsUsed: generatedAnswer.trace.revisionsUsed,
+ finalized: generatedAnswer.trace.finalized,
+ intentTask: generatedAnswer.trace.intent?.task ?? null,
+ retrievalCallCount: retrievalResponses.length,
+ })
+ return {
+ answer: HARNESS_VALIDATION_FAILURE_ANSWER,
+ citations: [] as ChatCitationView[],
+ artifacts: [] as ChatArtifactView[],
+ }
+ }
const rawResults = selectCitationRawResults({
generatedAnswer,
retrievalResponses,
sources: input.sources,
})
- if (rawResults.length === 0 && generatedAnswer.manifest.text.trim().length === 0) {
- return { answer: NO_RESULTS_ANSWER, citations: [] as ChatCitationView[] }
+ if (
+ rawResults.length === 0 &&
+ generatedAnswer.manifest.text.trim().length === 0 &&
+ !hasDisplayedManifestArtifacts(generatedAnswer)
+ ) {
+ return {
+ answer: NO_RESULTS_ANSWER,
+ citations: [] as ChatCitationView[],
+ artifacts: [] as ChatArtifactView[],
+ }
}
const results = yield* Effect.tryPromise(() =>
@@ -215,7 +240,7 @@ export const answerQuestionWithRetrieval = (
return {
answer,
citations: toChatCitationViews(citationResults, answer),
- ...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
+ artifacts: artifacts ?? [],
}
})
@@ -241,12 +266,15 @@ function toChatArtifactViewsFromHarness(
let displayedArtifactCount = 0
for (const artifact of result.manifest.artifacts) {
- const artifactView = resolveHarnessArtifactView({
- artifact,
- assetsByRef,
- chunksByRef,
- sources,
- })
+ const artifactView =
+ artifact.type === "derived_table"
+ ? toDerivedTableArtifactView(artifact)
+ : resolveHarnessArtifactView({
+ artifact,
+ assetsByRef,
+ chunksByRef,
+ sources,
+ })
if (!artifactView) continue
const isDisplayed = artifactView.display !== false
@@ -265,6 +293,21 @@ function toChatArtifactViewsFromHarness(
return artifacts.length > 0 ? artifacts : undefined
}
+function toDerivedTableArtifactView(
+ artifact: DerivedTableArtifact,
+): ChatArtifactView {
+ return {
+ type: "derived_table",
+ ref: artifact.ref,
+ title: artifact.title,
+ columns: artifact.columns,
+ rows: artifact.rows,
+ sourceRefs: artifact.sourceRefs,
+ display: artifact.display,
+ reason: artifact.reason,
+ }
+}
+
function getHarnessArtifactDisplayLimit(result: HarnessRunResult): number | null {
const constraints = result.trace.intent?.constraints
const limits = [constraints?.desiredCount, constraints?.maxCount].filter(
@@ -486,6 +529,10 @@ function selectCitationRawResults(input: {
}): RetrievalResult[] {
const curated = mapManifestCitationsToResults(input.generatedAnswer)
if (curated.length > 0) return curated
+ const displayedArtifacts = mapDisplayedManifestArtifactsToResults(
+ input.generatedAnswer,
+ )
+ if (displayedArtifacts.length > 0) return displayedArtifacts
return collectRetrievalResults(input.retrievalResponses, input.sources)
}
@@ -546,6 +593,87 @@ function resolveChunkForAssetRef(
return chunksByRef.get(asset.chunkRef)
}
+function mapDisplayedManifestArtifactsToResults(
+ result: HarnessRunResult,
+): RetrievalResult[] {
+ const chunksByRef = new Map(
+ result.trace.ledger.chunks.map((chunk): readonly [string, EvidenceChunk] => [
+ chunk.ref,
+ chunk,
+ ]),
+ )
+ const assetsByRef = new Map(
+ result.trace.ledger.assets.map((asset): readonly [string, EvidenceAsset] => [
+ asset.ref,
+ asset,
+ ]),
+ )
+
+ const results: RetrievalResult[] = []
+ const seenKeys = new Set()
+ const displayLimit = getHarnessArtifactDisplayLimit(result)
+
+ for (const artifact of result.manifest.artifacts) {
+ if (!artifact.display) continue
+ if (typeof displayLimit === "number" && results.length >= displayLimit) {
+ break
+ }
+
+ if (artifact.type === "derived_table") {
+ for (const sourceRef of artifact.sourceRefs) {
+ const chunk =
+ chunksByRef.get(sourceRef) ??
+ resolveChunkForAssetRef(sourceRef, assetsByRef, chunksByRef)
+ if (!chunk) continue
+
+ const retrievalResult = toRetrievalResultFromEvidenceChunk(chunk)
+ const key = getRetrievalResultKey(retrievalResult)
+ if (seenKeys.has(key)) continue
+
+ seenKeys.add(key)
+ results.push(retrievalResult)
+ if (results.length >= MAX_CITATION_RESULTS) return results
+ }
+ continue
+ }
+
+ const chunk =
+ chunksByRef.get(artifact.ref) ??
+ resolveChunkForAssetRef(artifact.ref, assetsByRef, chunksByRef)
+ if (!chunk) continue
+
+ const retrievalResult = toRetrievalResultFromEvidenceChunk(chunk)
+ const key = getRetrievalResultKey(retrievalResult)
+ if (seenKeys.has(key)) continue
+
+ seenKeys.add(key)
+ results.push(retrievalResult)
+ if (results.length >= MAX_CITATION_RESULTS) break
+ }
+
+ return results
+}
+
+function toRetrievalResultFromEvidenceChunk(
+ chunk: EvidenceChunk,
+): RetrievalResult {
+ return {
+ content: chunk.content,
+ chunkType: chunk.chunkType,
+ score: chunk.score,
+ ...(chunk.assetUrl ? { assetUrl: chunk.assetUrl } : {}),
+ source: {
+ documentId: chunk.source.documentId ?? undefined,
+ sourceFileName: chunk.source.sourceFileName ?? undefined,
+ sectionPath: chunk.source.sectionPath ?? undefined,
+ },
+ }
+}
+
+function hasDisplayedManifestArtifacts(result: HarnessRunResult): boolean {
+ return result.manifest.artifacts.some((artifact) => artifact.display)
+}
+
function collectRetrievalResults(
responses: readonly RetrievalQueryResponse[],
sources: readonly AnswerQuestionInput["sources"][number][],
diff --git a/src/domains/chat/service.test.ts b/src/domains/chat/service.test.ts
index 71da889..e931f04 100644
--- a/src/domains/chat/service.test.ts
+++ b/src/domains/chat/service.test.ts
@@ -78,6 +78,7 @@ describe("handleChatTurn", () => {
role: "assistant",
content: "Grounded answer.",
citations: [makeRetrievalResult()],
+ artifacts: [],
});
});
@@ -343,6 +344,9 @@ function makeHarnessRunResult(text: string): HarnessRunResult {
failureReasons: [],
decisionTraces: [],
},
+ finalized: true,
+ priorTurnReads: [],
+ toolCalls: [],
validationErrors: [],
revisionsUsed: 0,
},
diff --git a/src/domains/chat/types.ts b/src/domains/chat/types.ts
index fdda855..f3f987d 100644
--- a/src/domains/chat/types.ts
+++ b/src/domains/chat/types.ts
@@ -31,8 +31,12 @@ export type ChatCitationView = CitationView & {
}
export type ChatArtifactView = {
- readonly type: "image" | "table"
+ readonly type: "image" | "table" | "derived_table"
readonly ref?: string
+ readonly title?: string
+ readonly columns?: readonly string[]
+ readonly rows?: readonly (readonly string[])[]
+ readonly sourceRefs?: readonly string[]
readonly assetUrl?: string
readonly label?: string
readonly display?: boolean
diff --git a/src/domains/chat/view.ts b/src/domains/chat/view.ts
index 03987c7..9afc4f6 100644
--- a/src/domains/chat/view.ts
+++ b/src/domains/chat/view.ts
@@ -19,7 +19,7 @@ export function toChatThreadView(thread: ChatThread): ChatThreadView {
export function toChatMessageView(
message: ChatMessage,
citations: readonly ChatCitationView[] = [],
- artifacts: readonly ChatArtifactView[] = [],
+ artifacts?: readonly ChatArtifactView[],
): ChatMessageView {
const citationViews =
citations.length > 0
@@ -27,7 +27,7 @@ export function toChatMessageView(
: toPersistedCitationViews(message.citations)
const artifactViews =
- artifacts.length > 0
+ artifacts !== undefined
? [...artifacts]
: toPersistedArtifactViews(message.artifacts)
@@ -36,9 +36,7 @@ export function toChatMessageView(
role: message.role === "assistant" ? "assistant" : "user",
content: message.content,
citations: citationViews,
- ...(artifactViews && artifactViews.length > 0
- ? { artifacts: artifactViews }
- : {}),
+ ...(artifactViews !== undefined ? { artifacts: artifactViews } : {}),
}
}
@@ -68,11 +66,14 @@ function toPersistedCitationViews(value: unknown): ChatCitationView[] | undefine
function toPersistedArtifactViews(value: unknown): ChatArtifactView[] | undefined {
if (!Array.isArray(value)) return undefined
+ if (value.length === 0) return []
const artifacts = value.flatMap((item): ChatArtifactView[] => {
if (!isRecord(item)) return []
const type = getString(item.type)
- if (type !== "image" && type !== "table") return []
+ if (type !== "image" && type !== "table" && type !== "derived_table") {
+ return []
+ }
const citation =
isRecord(item.citation) && isRecord(item.citation.source)
@@ -89,6 +90,37 @@ function toPersistedArtifactViews(value: unknown): ChatArtifactView[] | undefine
}
: undefined
+ if (type === "derived_table") {
+ const title = getString(item.title)
+ const columns = getStringArray(item.columns)
+ const rows = getStringRows(item.rows)
+ const sourceRefs = getStringArray(item.sourceRefs)
+
+ if (
+ !title ||
+ !columns ||
+ columns.length === 0 ||
+ !rows ||
+ !sourceRefs ||
+ sourceRefs.length === 0
+ ) {
+ return []
+ }
+
+ return [
+ {
+ type,
+ ref: getString(item.ref),
+ title,
+ columns,
+ rows,
+ sourceRefs,
+ display: typeof item.display === "boolean" ? item.display : undefined,
+ reason: getString(item.reason),
+ },
+ ]
+ }
+
return [
{
type,
@@ -114,6 +146,23 @@ function getNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
+function getStringArray(value: unknown): readonly string[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ if (!value.every((item): item is string => typeof item === "string")) {
+ return undefined
+ }
+ return value
+}
+
+function getStringRows(
+ value: unknown,
+): readonly (readonly string[])[] | undefined {
+ if (!Array.isArray(value)) return undefined
+ const rows = value.map(getStringArray)
+ if (rows.some((row) => row === undefined)) return undefined
+ return rows as readonly (readonly string[])[]
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null
}