diff --git a/examples/openclaw-plugin/auto-recall.ts b/examples/openclaw-plugin/auto-recall.ts index 1538945cc3..73131c0ab6 100644 --- a/examples/openclaw-plugin/auto-recall.ts +++ b/examples/openclaw-plugin/auto-recall.ts @@ -12,6 +12,7 @@ import { resolveRecallSearchPlan, type RecallResourceType, } from "./registries/recall-resource-types.js"; +import { isPreviewableImageResourceUri, withPreviewUrls } from "./preview-url.js"; import { type RecallTraceEntry, type RecallTraceResult, @@ -99,6 +100,12 @@ export type BuildMemoryLinesOptions = { }; function memoryCategory(item: FindResultItem): string { + if (item.preview_url && isPreviewableImageResourceUri(item.uri)) { + return item.category?.trim() || "resource:image"; + } + if (item.uri.startsWith("viking://resources/")) { + return item.category?.trim() || "resource"; + } return item.category?.trim() || "memory"; } @@ -116,14 +123,18 @@ function formatMemoryLine( ): string { const category = memoryCategory(item); if (!options.includeUri) { - return `- [${category}] ${content}`; + return [ + `- [${category}] ${content}`, + item.preview_url ? ` ${item.preview_url}` : "", + ].filter(Boolean).join("\n"); } return [ `- [${category}]`, ` ${item.uri}`, + item.preview_url ? ` ${item.preview_url}` : "", indentContent(content), - ].join("\n"); + ].filter(Boolean).join("\n"); } async function resolveMemoryContent( @@ -212,13 +223,17 @@ export async function buildMemoryLinesWithBudget( } export function buildRecallContextBlock(memoryLines: string[]): string { + const previewInstruction = memoryLines.some((line) => line.includes("")) + ? "If you reference an image result, copy its exact into the answer; do not invent or rewrite preview URLs." + : ""; return [ "", AUTO_RECALL_SOURCE_MARKER, "The following OpenViking memories may be relevant:", + previewInstruction, ...memoryLines, "", - ].join("\n"); + ].filter(Boolean).join("\n"); } function newTraceId(): string { @@ -482,8 +497,11 @@ export async function buildAutoRecallContext(params: { return { memoryCount: 0, estimatedTokens: 0 }; } + const memoriesWithPreview = cfg.enableResourcePreviewUrls + ? await withPreviewUrls(memories, client, agentId) + : memories; const { lines: memoryLines, estimatedTokens } = await buildMemoryLinesWithBudget( - memories, + memoriesWithPreview, (uri) => client.read(uri, agentId), { recallPreferAbstract, @@ -505,10 +523,10 @@ export async function buildAutoRecallContext(params: { `openviking: injecting ${memoryLines.length} memories (${block.length} chars, ~${estimatedTokens} tokens, maxInjectedChars=${maxInjectedChars})`, ); verbose?.( - `openviking: inject-detail ${toJsonLog({ count: memories.length, memories: summarizeInjectionMemories(memories) })}`, + `openviking: inject-detail ${toJsonLog({ count: memoriesWithPreview.length, memories: summarizeInjectionMemories(memoriesWithPreview) })}`, ); - await recordTrace(memories.slice(0, memoryLines.length), memoryLines.length, estimatedTokens); + await recordTrace(memoriesWithPreview.slice(0, memoryLines.length), memoryLines.length, estimatedTokens); return { block, memoryCount: memoryLines.length, estimatedTokens }; })(), cfg.autoRecallTimeoutMs, diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts index d6d2f932b2..322f8325b7 100644 --- a/examples/openclaw-plugin/client.ts +++ b/examples/openclaw-plugin/client.ts @@ -21,6 +21,7 @@ export type FindResultItem = { category?: string; score?: number; match_reason?: string; + preview_url?: string; }; export type FindResult = { @@ -30,6 +31,10 @@ export type FindResult = { total?: number; }; +export type PreviewUrlResult = { + preview_url: string; +}; + export type FsListEntry = string | Record; export type FsListResult = FsListEntry[]; @@ -485,6 +490,16 @@ export class OpenVikingClient { ); } + async getPreviewUrl(uri: string, actorPeerId?: string): Promise { + const result = await this.request( + `/api/v1/content/preview_url?uri=${encodeURIComponent(uri)}`, + {}, + undefined, + actorPeerId, + ); + return result.preview_url; + } + async list( uri: string, options?: { diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index dbbc03182d..b702b9f220 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -20,6 +20,8 @@ export type MemoryOpenVikingConfig = { autoRecall?: boolean; /** Outer time budget for the whole auto-recall flow, including search, ranking, and reads. */ autoRecallTimeoutMs?: number; + /** Hidden switch for temporary preview URLs on recalled image resources. Default false. */ + enableResourcePreviewUrls?: boolean; /** Include resources in auto-recall and default memory_recall search. Default false. */ recallResources?: boolean; recallLimit?: number; @@ -394,6 +396,7 @@ export const memoryOpenVikingConfigSchema = { "captureMaxLength", "autoRecall", "autoRecallTimeoutMs", + "enableResourcePreviewUrls", "recallResources", "recallLimit", "recallScoreThreshold", @@ -503,6 +506,7 @@ export const memoryOpenVikingConfigSchema = { 1000, Math.min(300_000, Math.floor(toNumber(cfg.autoRecallTimeoutMs, DEFAULT_AUTO_RECALL_TIMEOUT_MS))), ), + enableResourcePreviewUrls: cfg.enableResourcePreviewUrls === true, recallResources, recallLimit: Math.max(1, Math.floor(toNumber(cfg.recallLimit, DEFAULT_RECALL_LIMIT))), recallScoreThreshold: Math.min( diff --git a/examples/openclaw-plugin/install-manifest.json b/examples/openclaw-plugin/install-manifest.json index 23a8c69691..227e55c593 100644 --- a/examples/openclaw-plugin/install-manifest.json +++ b/examples/openclaw-plugin/install-manifest.json @@ -13,6 +13,7 @@ "context-engine.ts", "auto-recall.ts", "client.ts", + "preview-url.ts", "process-manager.ts", "memory-ranking.ts", "token-estimator.ts", diff --git a/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts b/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts index 98b8d02e90..b30b94b60c 100644 --- a/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts +++ b/examples/openclaw-plugin/plugin/openviking-memory-recall-tools.ts @@ -5,6 +5,7 @@ import type { BuildMemoryLinesWithBudgetOptions } from "../auto-recall.js"; import type { RankingOptions } from "../memory-ranking.js"; import type { EffectiveQueryConfig, QueryConfigContext, RuntimeQueryParams } from "../query-config.js"; import type { RecallResourceType } from "../registries/recall-resource-types.js"; +import { withPreviewUrls } from "../preview-url.js"; import type { RecallTraceEntry, RecallTraceResult, @@ -36,6 +37,7 @@ export type OpenVikingMemoryRecallClient = { }, ) => Promise; read: (uri: string, agentId?: string) => Promise; + getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise; getDefaultAgentId: () => string; }; @@ -89,6 +91,7 @@ export type OpenVikingMemoryRecallToolsDeps = { traceRecallPreviewChars: number; traceRecallQueryMaxChars: number; logFindRequests: boolean; + enableResourcePreviewUrls?: boolean; }; logger: { info?: (message: string) => void; @@ -254,16 +257,22 @@ export function registerOpenVikingMemoryRecallTools( }; } - const leafOnly = (result.memories ?? []).filter((m) => !m.level || m.level === 2); + const leafOnly = [ + ...(result.memories ?? []), + ...(result.resources ?? []), + ].filter((m) => !m.level || m.level === 2); const processed = deps.postProcessMemories(leafOnly, { limit: requestLimit, scoreThreshold, }); - const memories = deps.pickMemoriesForInjection(processed, limit, query, scoreThreshold, { + const pickedMemories = deps.pickMemoriesForInjection(processed, limit, query, scoreThreshold, { weights: queryConfig.rankingWeights, categoryWeights: queryConfig.categoryWeights, resourceTypeWeights: queryConfig.resourceTypeWeights, }); + const memories = deps.cfg.enableResourcePreviewUrls + ? await withPreviewUrls(pickedMemories, recallClient, session.agentId) + : pickedMemories; const candidateTraceResults = leafOnly .map((item) => toTraceResult(item, deps.inferRecallResourceType(item.uri) === "resource" ? "resource" : "memory", deps)) .slice(0, deps.cfg.traceRecallMaxResultsPerSearch); diff --git a/examples/openclaw-plugin/plugin/openviking-query-formatters.ts b/examples/openclaw-plugin/plugin/openviking-query-formatters.ts index 42dcbc96aa..f4a207b460 100644 --- a/examples/openclaw-plugin/plugin/openviking-query-formatters.ts +++ b/examples/openclaw-plugin/plugin/openviking-query-formatters.ts @@ -22,6 +22,7 @@ export function formatOVSearchRows(result: FindResult): string[] { if (items.length === 0) { return []; } + const includePreviewUrls = items.some(({ item }) => Boolean(item.preview_url)); const numberHeader = "no"; const numberWidth = Math.max(numberHeader.length, String(items.length).length); const typeWidth = Math.max("type".length, ...items.map(({ contextType }) => contextType.length)); @@ -31,12 +32,17 @@ export function formatOVSearchRows(result: FindResult): string[] { "score".length, ...items.map(({ item }) => (typeof item.score === "number" ? item.score.toFixed(2).length : 0)), ); + const previewWidth = includePreviewUrls + ? Math.max("preview_url".length, ...items.map(({ item }) => item.preview_url?.length ?? 0)) + : 0; + const previewHeader = includePreviewUrls ? ` ${"preview_url".padEnd(previewWidth)}` : ""; return [ - `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)} abstract`, + `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)}${previewHeader} abstract`, ...items.map(({ contextType, item }, index) => { const score = typeof item.score === "number" ? item.score.toFixed(2) : ""; + const previewColumn = includePreviewUrls ? ` ${(item.preview_url ?? "").padEnd(previewWidth)}` : ""; const summary = truncateSummary(item.abstract || item.overview || "(no summary)"); - return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)} ${summary}`; + return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)}${previewColumn} ${summary}`; }), ]; } diff --git a/examples/openclaw-plugin/plugin/openviking-query-runtime.ts b/examples/openclaw-plugin/plugin/openviking-query-runtime.ts index c57c504949..f5a029efb0 100644 --- a/examples/openclaw-plugin/plugin/openviking-query-runtime.ts +++ b/examples/openclaw-plugin/plugin/openviking-query-runtime.ts @@ -1,4 +1,5 @@ import type { FindResult, FindResultItem, FsListResult } from "../client.js"; +import { withFindResultPreviewUrls } from "../preview-url.js"; import type { RecallResourceType } from "../registries/recall-resource-types.js"; import type { RecallTraceEntry, RecallTraceResult } from "../recall-trace.js"; @@ -52,6 +53,7 @@ type OpenVikingQueryClient = { agentId?: string, ) => Promise; read: (uri: string, agentId?: string) => Promise; + getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise; list: ( uri: string, options?: { recursive?: boolean; simple?: boolean; nodeLimit?: number }, @@ -77,6 +79,7 @@ export type OpenVikingQueryRuntimeDeps = { traceRecallMaxResultsPerSearch: number; traceRecallPreviewChars: number; traceRecallQueryMaxChars: number; + enableResourcePreviewUrls?: boolean; }; }; @@ -117,6 +120,7 @@ function formatOVSearchRows(result: FindResult): string[] { if (items.length === 0) { return []; } + const includePreviewUrls = items.some(({ item }) => Boolean(item.preview_url)); const numberHeader = "no"; const numberWidth = Math.max(numberHeader.length, String(items.length).length); const typeWidth = Math.max("type".length, ...items.map(({ contextType }) => contextType.length)); @@ -126,12 +130,17 @@ function formatOVSearchRows(result: FindResult): string[] { "score".length, ...items.map(({ item }) => (typeof item.score === "number" ? item.score.toFixed(2).length : 0)), ); + const previewWidth = includePreviewUrls + ? Math.max("preview_url".length, ...items.map(({ item }) => item.preview_url?.length ?? 0)) + : 0; + const previewHeader = includePreviewUrls ? ` ${"preview_url".padEnd(previewWidth)}` : ""; return [ - `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)} abstract`, + `${numberHeader.padEnd(numberWidth)} ${"type".padEnd(typeWidth)} ${"uri".padEnd(uriWidth)} ${"level".padEnd(levelWidth)} ${"score".padEnd(scoreWidth)}${previewHeader} abstract`, ...items.map(({ contextType, item }, index) => { const score = typeof item.score === "number" ? item.score.toFixed(2) : ""; + const previewColumn = includePreviewUrls ? ` ${(item.preview_url ?? "").padEnd(previewWidth)}` : ""; const summary = truncateSummary(item.abstract || item.overview || "(no summary)"); - return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)} ${summary}`; + return `${String(index + 1).padEnd(numberWidth)} ${contextType.padEnd(typeWidth)} ${item.uri.padEnd(uriWidth)} ${String(item.level ?? "").padEnd(levelWidth)} ${score.padEnd(scoreWidth)}${previewColumn} ${summary}`; }), ]; } @@ -412,6 +421,9 @@ export function createOpenVikingQueryRuntime(deps: OpenViki } result = mergeFindResults(successful); } + if (deps.cfg.enableResourcePreviewUrls) { + result = await withFindResultPreviewUrls(result, client, agentId); + } const selected = [ ...(result.memories ?? []).map((item) => ({ uri: item.uri, diff --git a/examples/openclaw-plugin/preview-url.ts b/examples/openclaw-plugin/preview-url.ts new file mode 100644 index 0000000000..f8309f129d --- /dev/null +++ b/examples/openclaw-plugin/preview-url.ts @@ -0,0 +1,68 @@ +import type { FindResult, FindResultItem } from "./client.js"; + +const IMAGE_EXTENSIONS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ".bmp", + ".svg", + ".avif", +]); + +export type PreviewUrlClient = { + getPreviewUrl?: (uri: string, actorPeerId?: string) => Promise; +}; + +export function stripUriFragment(uri: string): string { + const hashIndex = uri.indexOf("#"); + return hashIndex >= 0 ? uri.slice(0, hashIndex) : uri; +} + +export function isPreviewableImageResourceUri(uri: string): boolean { + const normalized = stripUriFragment(uri).toLowerCase(); + if (!normalized.startsWith("viking://resources/")) { + return false; + } + return [...IMAGE_EXTENSIONS].some((ext) => normalized.endsWith(ext)); +} + +export async function withPreviewUrls( + items: FindResultItem[], + client: PreviewUrlClient, + actorPeerId?: string, +): Promise { + const getPreviewUrl = client.getPreviewUrl; + if (typeof getPreviewUrl !== "function") { + return items; + } + + const enriched = await Promise.all(items.map(async (item) => { + if (item.preview_url || !isPreviewableImageResourceUri(item.uri)) { + return item; + } + try { + const previewUrl = await getPreviewUrl(stripUriFragment(item.uri), actorPeerId); + return previewUrl ? { ...item, preview_url: previewUrl } : item; + } catch { + return item; + } + })); + + return enriched; +} + +export async function withFindResultPreviewUrls( + result: FindResult, + client: PreviewUrlClient, + actorPeerId?: string, +): Promise { + const resources = result.resources + ? await withPreviewUrls(result.resources, client, actorPeerId) + : result.resources; + return { + ...result, + resources, + }; +} diff --git a/examples/openclaw-plugin/setup-helper/install.js b/examples/openclaw-plugin/setup-helper/install.js index 652e7aca18..733a935f4e 100755 --- a/examples/openclaw-plugin/setup-helper/install.js +++ b/examples/openclaw-plugin/setup-helper/install.js @@ -71,6 +71,7 @@ const FALLBACK_CURRENT = { "context-engine.ts", "auto-recall.ts", "client.ts", + "preview-url.ts", "process-manager.ts", "memory-ranking.ts", "text-utils.ts", diff --git a/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts b/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts index 9c19c86b77..26bca47acf 100644 --- a/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts +++ b/examples/openclaw-plugin/tests/ut/build-memory-lines.test.ts @@ -84,6 +84,52 @@ describe("buildMemoryLines", () => { ]); }); + it("includes preview_url metadata when present", async () => { + const memories = [ + makeMemory({ + uri: "viking://resources/gallery/photo.png", + category: "", + abstract: "A reference photo.", + preview_url: "https://tos.example.com/photo.png?sig=1", + }), + ]; + const readFn = vi.fn(); + + const lines = await buildMemoryLines(memories, readFn, { + recallPreferAbstract: true, + includeUri: true, + }); + + expect(lines).toEqual([ + [ + "- [resource:image]", + " viking://resources/gallery/photo.png", + " https://tos.example.com/photo.png?sig=1", + " A reference photo.", + ].join("\n"), + ]); + }); + + it("includes preview_url without uri metadata for explicit recall output", async () => { + const memories = [ + makeMemory({ + uri: "viking://resources/gallery/photo.jpg", + category: "", + abstract: "A second reference photo.", + preview_url: "https://tos.example.com/photo.jpg?sig=1", + }), + ]; + const readFn = vi.fn(); + + const lines = await buildMemoryLines(memories, readFn, { + recallPreferAbstract: true, + }); + + expect(lines).toEqual([ + "- [resource:image] A second reference photo.\n https://tos.example.com/photo.jpg?sig=1", + ]); + }); + it("uses abstract when recallPreferAbstract=true", async () => { const memories = [makeMemory({ abstract: "The abstract text" })]; const readFn = vi.fn(); @@ -224,6 +270,30 @@ describe("buildMemoryLinesWithBudget", () => { expect(estimatedTokens).toBe(0); }); + it("counts preview_url text against the injected character budget", async () => { + const memories = [ + makeMemory({ + uri: "viking://resources/gallery/too-large.png", + abstract: "short", + preview_url: "https://tos.example.com/" + "x".repeat(100), + }), + ]; + const readFn = vi.fn(); + + const { lines, estimatedTokens } = await buildMemoryLinesWithBudget( + memories, + readFn, + { + recallPreferAbstract: true, + recallMaxInjectedChars: 30, + includeUri: true, + }, + ); + + expect(lines).toHaveLength(0); + expect(estimatedTokens).toBe(0); + }); + it("returns correct estimatedTokens sum", async () => { const memories = [ makeMemory({ abstract: "short" }), @@ -453,4 +523,95 @@ describe("buildAutoRecallContext trace", () => { expect(recorded.selected[0]).toMatchObject({ injected: true }); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("auto-recall search failed")); }); + + it("injects preview_url for auto-recalled image resources", async () => { + const cfg = makeCfg({ + recallTargetTypes: ["resource"], + recallPreferAbstract: true, + enableResourcePreviewUrls: true, + }); + const image = makeMemory({ + uri: "viking://resources/gallery/photo.png#chunk-1", + category: "", + abstract: "Reference product image.", + score: 0.9, + }); + const client = { + healthCheck: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }), + read: vi.fn().mockResolvedValue("unused"), + getPreviewUrl: vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"), + }; + + const result = await buildAutoRecallContext({ + cfg, + client: client as any, + agentId: "agent-1", + queryText: "show me the product image", + logger: { info: vi.fn(), warn: vi.fn() }, + }); + + expect(client.getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-1"); + expect(result.block).toContain("https://tos.example.com/photo.png?sig=1"); + expect(result.block).toContain("If you reference an image result"); + }); + + it("does not call preview_url lookup when resource preview urls are disabled", async () => { + const cfg = makeCfg({ recallTargetTypes: ["resource"], recallPreferAbstract: true }); + const image = makeMemory({ + uri: "viking://resources/gallery/photo.png#chunk-1", + category: "", + abstract: "Reference product image.", + score: 0.9, + }); + const client = { + healthCheck: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }), + read: vi.fn().mockResolvedValue("unused"), + getPreviewUrl: vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"), + }; + + const result = await buildAutoRecallContext({ + cfg, + client: client as any, + agentId: "agent-1", + queryText: "show me the product image", + logger: { info: vi.fn(), warn: vi.fn() }, + }); + + expect(client.getPreviewUrl).not.toHaveBeenCalled(); + expect(result.block).toContain("Reference product image."); + expect(result.block).not.toContain(""); + expect(result.block).not.toContain("If you reference an image result"); + }); + + it("keeps auto-recall injection when preview_url lookup fails", async () => { + const cfg = makeCfg({ + recallTargetTypes: ["resource"], + recallPreferAbstract: true, + enableResourcePreviewUrls: true, + }); + const image = makeMemory({ + uri: "viking://resources/gallery/photo.webp", + abstract: "Fallback image abstract.", + score: 0.9, + }); + const client = { + healthCheck: vi.fn().mockResolvedValue(undefined), + find: vi.fn().mockResolvedValue({ resources: [image], total: 1 }), + read: vi.fn().mockResolvedValue("unused"), + getPreviewUrl: vi.fn().mockRejectedValue(new Error("preview unavailable")), + }; + + const result = await buildAutoRecallContext({ + cfg, + client: client as any, + agentId: "agent-1", + queryText: "show me the fallback image", + logger: { info: vi.fn(), warn: vi.fn() }, + }); + + expect(result.block).toContain("Fallback image abstract."); + expect(result.block).not.toContain("https://"); + }); }); diff --git a/examples/openclaw-plugin/tests/ut/client.test.ts b/examples/openclaw-plugin/tests/ut/client.test.ts index 3b485b4295..517711ac6f 100644 --- a/examples/openclaw-plugin/tests/ut/client.test.ts +++ b/examples/openclaw-plugin/tests/ut/client.test.ts @@ -82,6 +82,35 @@ describe("isMemoryUri", () => { }); describe("OpenVikingClient resource and skill import", () => { + it("getPreviewUrl requests the content preview URL with plugin auth headers", async () => { + const transport = vi.fn().mockResolvedValue( + okResponse({ preview_url: "https://tos.example.com/image.png?sig=1" }), + ); + + const client = new OpenVikingClient("http://127.0.0.1:1933", "sk-user", "agent", 5000, "", "", undefined, false, true, { transport }); + const result = await client.getPreviewUrl("viking://resources/gallery/image.png#chunk-1", "agent-main"); + + expect(result).toBe("https://tos.example.com/image.png?sig=1"); + expect(transport).toHaveBeenCalledTimes(1); + const [url, init] = transport.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("http://127.0.0.1:1933/api/v1/content/preview_url?uri=viking%3A%2F%2Fresources%2Fgallery%2Fimage.png%23chunk-1"); + const headers = new Headers(init.headers); + expect(headers.get("X-API-Key")).toBe("sk-user"); + expect(headers.get("X-OpenViking-Actor-Peer")).toBe("agent-main"); + }); + + it("getPreviewUrl surfaces OpenViking error payloads through the shared request path", async () => { + const transport = vi.fn().mockResolvedValue( + errorResponse("cannot preview directory", "InvalidParameter"), + ); + + const client = new OpenVikingClient("http://127.0.0.1:1933", "sk-user", "agent", 5000, "", "", undefined, false, true, { transport }); + + await expect(client.getPreviewUrl("viking://resources/gallery/")).rejects.toThrow( + "OpenViking request failed [InvalidParameter]: cannot preview directory", + ); + }); + it("addResource posts remote URL as path", async () => { const transport = vi.fn().mockResolvedValue( okResponse({ root_uri: "viking://resources/site", status: "success" }), diff --git a/examples/openclaw-plugin/tests/ut/config.test.ts b/examples/openclaw-plugin/tests/ut/config.test.ts index b67613351f..cc9527c94d 100644 --- a/examples/openclaw-plugin/tests/ut/config.test.ts +++ b/examples/openclaw-plugin/tests/ut/config.test.ts @@ -23,6 +23,7 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { expect(cfg.captureMode).toBe("semantic"); expect(cfg.captureMaxLength).toBe(24000); expect(cfg.autoRecallTimeoutMs).toBe(5000); + expect(cfg.enableResourcePreviewUrls).toBe(false); expect(cfg.recallMaxContentChars).toBe(5000); expect(cfg.peer_role).toBe("assistant"); expect(cfg.peer_prefix).toBe(""); @@ -64,6 +65,14 @@ describe("memoryOpenVikingConfigSchema.parse()", () => { expect(enabled.disabledTools).not.toContain("add_resource"); }); + it("enables resource preview urls only when explicitly allowed", () => { + const disabled = memoryOpenVikingConfigSchema.parse({}); + expect(disabled.enableResourcePreviewUrls).toBe(false); + + const enabled = memoryOpenVikingConfigSchema.parse({ enableResourcePreviewUrls: true }); + expect(enabled.enableResourcePreviewUrls).toBe(true); + }); + it("expands enabled and disabled tool groups", () => { const cfg = memoryOpenVikingConfigSchema.parse({ enabledTools: ["resource_query", "memory"], diff --git a/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts b/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts index aae9ea48ca..92ec3c8db9 100644 --- a/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts +++ b/examples/openclaw-plugin/tests/ut/plugin-modules.test.ts @@ -292,6 +292,99 @@ describe("plugin module seams", () => { expect(readResult.content[0].text).toContain("--- START OF viking://resources/spec.md ---"); }); + it("adds preview_url to ov_search image resource output", async () => { + const find = vi.fn().mockResolvedValue({ + memories: [], + resources: [{ + uri: "viking://resources/gallery/photo.png#chunk-1", + abstract: "Reference image", + score: 0.91, + category: "image", + }], + skills: [], + total: 1, + }); + const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"); + const runtime = createOpenVikingQueryRuntime({ + getClient: async () => ({ + find, + read: vi.fn(), + list: vi.fn().mockResolvedValue([]), + getPreviewUrl, + }), + queryConfigStore: { getEffective: vi.fn().mockResolvedValue({ ovSearchLimit: 2 }) }, + toQueryConfigContext: (session) => session, + traceRecorder: { recordAndFlush: vi.fn() }, + inferRecallResourceType: () => "resource", + createTraceId: () => "trace-query-preview", + boundTraceQuery: (query) => ({ query }), + previewText: (value) => typeof value === "string" ? value : undefined, + logger: { warn: vi.fn() }, + cfg: { + traceRecallMaxResultsPerSearch: 5, + traceRecallPreviewChars: 80, + traceRecallQueryMaxChars: 120, + enableResourcePreviewUrls: true, + }, + }); + + const search = await runtime.searchOpenViking( + { query: "photo", uri: "viking://resources/gallery" }, + "agent-main", + { agentId: "agent-main", sessionId: "session-1" }, + ) as any; + + expect(getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-main"); + expect(search.content[0].text).toContain("https://tos.example.com/photo.png?sig=1"); + expect(search.details.resources[0].preview_url).toBe("https://tos.example.com/photo.png?sig=1"); + }); + + it("does not fetch preview_url for ov_search when resource preview urls are disabled", async () => { + const find = vi.fn().mockResolvedValue({ + memories: [], + resources: [{ + uri: "viking://resources/gallery/photo.png#chunk-1", + abstract: "Reference image", + score: 0.91, + category: "image", + }], + skills: [], + total: 1, + }); + const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"); + const runtime = createOpenVikingQueryRuntime({ + getClient: async () => ({ + find, + read: vi.fn(), + list: vi.fn().mockResolvedValue([]), + getPreviewUrl, + }), + queryConfigStore: { getEffective: vi.fn().mockResolvedValue({ ovSearchLimit: 2 }) }, + toQueryConfigContext: (session) => session, + traceRecorder: { recordAndFlush: vi.fn() }, + inferRecallResourceType: () => "resource", + createTraceId: () => "trace-query-preview-disabled", + boundTraceQuery: (query) => ({ query }), + previewText: (value) => typeof value === "string" ? value : undefined, + logger: { warn: vi.fn() }, + cfg: { + traceRecallMaxResultsPerSearch: 5, + traceRecallPreviewChars: 80, + traceRecallQueryMaxChars: 120, + }, + }); + + const search = await runtime.searchOpenViking( + { query: "photo", uri: "viking://resources/gallery" }, + "agent-main", + { agentId: "agent-main", sessionId: "session-1" }, + ) as any; + + expect(getPreviewUrl).not.toHaveBeenCalled(); + expect(search.content[0].text).not.toContain("preview_url"); + expect(search.details.resources[0].preview_url).toBeUndefined(); + }); + it("handles OpenViking query config commands through a dedicated module", async () => { const session = { agentId: "agent-main", sessionId: "session-1", sessionKey: "key-1", ovSessionId: "ov-session-1" }; const queryCtx = { agentId: "agent-main", sessionId: "session-1", sessionKey: "key-1", ovSessionId: "ov-session-1" }; @@ -1005,4 +1098,82 @@ describe("plugin module seams", () => { recallMaxInjectedChars: 500, }); }); + + it("adds preview_url to memory_recall image resource lines before formatting", async () => { + const registerTool = vi.fn(); + const image = { + uri: "viking://resources/gallery/photo.png#chunk-2", + category: "", + abstract: "Reference product image.", + score: 0.93, + level: 2, + }; + const find = vi.fn().mockResolvedValue({ memories: [], resources: [image], total: 1 }); + const read = vi.fn().mockResolvedValue("Reference product image."); + const getPreviewUrl = vi.fn().mockResolvedValue("https://tos.example.com/photo.png?sig=1"); + const getDefaultAgentId = vi.fn().mockReturnValue("default-agent"); + const buildMemoryLinesWithBudget = vi.fn(async (items, readFn) => { + await readFn(items[0].uri); + return { + lines: [`- [resource:image] ${items[0].abstract}\n ${items[0].preview_url}`], + estimatedTokens: 12, + }; + }); + + registerOpenVikingMemoryRecallTools({ + registerTool, + getClient: async () => ({ find, read, getPreviewUrl, getDefaultAgentId }), + queryConfigStore: { + getEffective: vi.fn().mockResolvedValue({ + recallLimit: 1, + scoreThreshold: 0.5, + targetUri: "viking://resources/gallery", + resourceTypes: ["resource"], + candidateLimit: 4, + maxInjectedChars: 500, + rankingWeights: {}, + categoryWeights: {}, + resourceTypeWeights: {}, + }), + }, + toQueryConfigContext: (session) => ({ agentId: session.agentId, sessionId: session.sessionId, sessionKey: session.sessionKey, ovSessionId: session.ovSessionId }), + resolvePluginSessionRouting: () => ({ + agentId: "agent-main", + sessionId: "session-1", + sessionKey: "agent:agent-main:session-1", + ovSessionId: "ov-session-1", + }), + isBypassedSession: () => false, + makeBypassedToolResult: (toolName: string) => ({ content: [{ type: "text" as const, text: `bypassed ${toolName}` }], details: { toolName } }), + resolveRecallSearchPlan: vi.fn(), + postProcessMemories: (items) => items, + pickMemoriesForInjection: (items) => items.slice(0, 1), + buildMemoryLinesWithBudget, + inferRecallResourceType: (uri) => uri.startsWith("viking://resources/") ? "resource" : "user", + createTraceId: () => "memory_recall-preview-trace", + boundTraceQuery: (query) => ({ query }), + previewText: (value) => typeof value === "string" ? value : undefined, + traceRecorder: { recordAndFlush: vi.fn() }, + cfg: { + recallTargetTypes: ["resource"], + traceRecallMaxResultsPerSearch: 5, + traceRecallPreviewChars: 120, + traceRecallQueryMaxChars: 200, + logFindRequests: false, + enableResourcePreviewUrls: true, + }, + logger: { info: vi.fn() }, + }); + + const recallFactory = registerTool.mock.calls[0]?.[0]; + const recallTool = recallFactory({ sessionId: "session-1" }); + const result = await recallTool.execute("call-1", { query: "product image" }); + + expect(getPreviewUrl).toHaveBeenCalledWith("viking://resources/gallery/photo.png", "agent-main"); + expect(buildMemoryLinesWithBudget.mock.calls[0]![0][0]).toMatchObject({ + uri: "viking://resources/gallery/photo.png#chunk-2", + preview_url: "https://tos.example.com/photo.png?sig=1", + }); + expect(result.content[0].text).toContain("https://tos.example.com/photo.png?sig=1"); + }); }); diff --git a/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts b/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts index 238f241e97..ee9e6e07a4 100644 --- a/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts +++ b/examples/openclaw-plugin/tests/ut/plugin-query-formatters.test.ts @@ -16,7 +16,12 @@ describe("openviking query formatters", () => { { uri: "viking://user/memory/1", level: 2, score: 0.987, abstract: "remember this" }, ], resources: [ - { uri: "viking://resources/doc", score: 0.7, overview: "resource overview" }, + { + uri: "viking://resources/doc", + score: 0.7, + overview: "resource overview", + preview_url: "https://tos.example.com/doc?sig=1", + }, ], skills: [ { uri: "skill://openviking-context-database", abstract: "skill overview" }, @@ -25,14 +30,26 @@ describe("openviking query formatters", () => { }); expect(rows[0]).toContain("no type"); + expect(rows[0]).toContain("preview_url"); expect(rows[1]).toContain("memory"); expect(rows[1]).toContain("viking://user/memory/1"); expect(rows[1]).toContain("0.99"); expect(rows[1]).toContain("remember this"); expect(rows.join("\n")).toContain("resource overview"); + expect(rows.join("\n")).toContain("https://tos.example.com/doc?sig=1"); expect(rows.join("\n")).toContain("skill overview"); }); + it("omits preview_url column when no search row has a preview url", () => { + const rows = formatOVSearchRows({ + resources: [{ uri: "viking://resources/doc", score: 0.7, overview: "resource overview" }], + total: 1, + }); + + expect(rows[0]).not.toContain("preview_url"); + expect(rows.join("\n")).toContain("resource overview"); + }); + it("formats search text and empty search text", () => { expect(formatOVSearchText("query", "viking://resources", { total: 0 })).toBe( 'No OpenViking resource or skill results found for "query" under viking://resources.',