新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染
- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
This commit is contained in:
1 parent
9a63a4b7ca
commit
8892637b97
26 files changed
+2732
-470
No files matched your search
@@ -0,0 +1,209 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
export interface DisplaySourceItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
enabled: false,
|
||||
mode: "none",
|
||||
resultPath: "results",
|
||||
titleField: null,
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
export const mockKnowledgeSearchV2Result = {
|
||||
success: true,
|
||||
query: "latest news",
|
||||
count: 3,
|
||||
results: [
|
||||
{
|
||||
index: 1,
|
||||
recUuid: "rec-001",
|
||||
relevance_score: 0.982,
|
||||
title: "Knowledge result one",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
publish_time: "2026-05-25",
|
||||
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
||||
content_length: 1200,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
recUuid: "rec-002",
|
||||
relevance_score: 0.951,
|
||||
title: "Knowledge result two",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
publish_time: "2026-05-24",
|
||||
content_preview: "Mock result used to verify citation ordering and detail preview.",
|
||||
content_length: 980,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
recUuid: "rec-003",
|
||||
relevance_score: 0.921,
|
||||
title: "Knowledge result three",
|
||||
source_category: "knowledge/commentary",
|
||||
source: "internal knowledge library",
|
||||
publish_time: "2026-05-23",
|
||||
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
|
||||
content_length: 760,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const candidateFields: Record<string, string[]> = {
|
||||
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
urlField: ["url", "link", "href", "source_url"],
|
||||
idField: ["recUuid", "id", "uuid", "docId", "taskId", "recordId"],
|
||||
};
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
if (!path) return value;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function findFirstArray(value: unknown, path = ""): { path: string; items: Record<string, unknown>[] } | null {
|
||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||
return { path, items: value as Record<string, unknown>[] };
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||
const keys = Object.keys(sample);
|
||||
for (const candidate of candidates) {
|
||||
const exact = keys.find((key) => key === candidate);
|
||||
if (exact) return exact;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||
const sample = found.items[0] ?? {};
|
||||
const inferred = emptyDisplayConfig();
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
}
|
||||
|
||||
if (skillName === "knowledge-search-v2") {
|
||||
inferred.titleField = inferred.titleField || "title";
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
inferred.idField = inferred.idField || "recUuid";
|
||||
inferred.linkStrategy = "id";
|
||||
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||
}
|
||||
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildDefaultTableColumns(
|
||||
sample: Record<string, unknown>,
|
||||
config: SkillDisplayConfig,
|
||||
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||
const preferred = [
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
const fallback = Object.keys(sample)
|
||||
.filter((field) => !preferred.includes(field))
|
||||
.slice(0, Math.max(0, 6 - preferred.length));
|
||||
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||
label: field,
|
||||
field,
|
||||
}));
|
||||
}
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => encodeURIComponent(stringify(item[key])));
|
||||
}
|
||||
|
||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||
if (config.linkStrategy === "url") return url || undefined;
|
||||
if (config.linkStrategy === "id") return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||
}
|
||||
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
return rawItems
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, config.maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`,
|
||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score: config.scoreField && typeof item[config.scoreField] === "number" ? item[config.scoreField] as number : undefined,
|
||||
url: resolveLink(item, config),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { normalizeSearchResults } from "@/core/messages/search-results";
|
||||
import type { Skill, SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
import {
|
||||
normalizeDisplaySources,
|
||||
type DisplaySourceItem,
|
||||
} from "./display-adapter";
|
||||
|
||||
export type RuntimeDisplaySource = DisplaySourceItem & {
|
||||
index: number;
|
||||
skillName: string;
|
||||
mode: SkillDisplayConfig["mode"];
|
||||
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||||
};
|
||||
|
||||
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
|
||||
|
||||
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
|
||||
const map = new Map<string, SkillDisplayConfig>();
|
||||
for (const skill of skills) {
|
||||
const display = skill.display;
|
||||
if (!display?.enabled || display.mode === "none") {
|
||||
continue;
|
||||
}
|
||||
map.set(skill.name, display);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findDisplayConfigForTool(
|
||||
toolName: string,
|
||||
displays: SkillDisplayMap,
|
||||
toolArgs?: Record<string, unknown>,
|
||||
): { skillName: string; display: SkillDisplayConfig } | null {
|
||||
const exact = displays.get(toolName);
|
||||
if (exact) {
|
||||
return { skillName: toolName, display: exact };
|
||||
}
|
||||
|
||||
const normalizedTool = normalizeName(toolName);
|
||||
for (const [skillName, display] of displays) {
|
||||
const normalizedSkill = normalizeName(skillName);
|
||||
if (
|
||||
normalizedTool === normalizedSkill ||
|
||||
normalizedTool.endsWith(normalizedSkill) ||
|
||||
normalizedTool.includes(normalizedSkill)
|
||||
) {
|
||||
return { skillName, display };
|
||||
}
|
||||
}
|
||||
|
||||
// bash/python skill invocations don't carry the skill name in the tool name —
|
||||
// the path lives inside the command/args (e.g. `python3 skills/custom/demo/scripts/run.py`).
|
||||
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
|
||||
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
|
||||
// tool_metrics_middleware.py.
|
||||
if (toolArgs) {
|
||||
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
||||
if (skillFromArgs) {
|
||||
const direct = displays.get(skillFromArgs);
|
||||
if (direct) {
|
||||
return { skillName: skillFromArgs, display: direct };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
||||
|
||||
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const match = SKILL_PATH_RE.exec(value);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function collectRuntimeDisplaySources(
|
||||
messages: Message[],
|
||||
displays: SkillDisplayMap,
|
||||
options: {
|
||||
afterMessageId?: string;
|
||||
beforeMessageId?: string;
|
||||
includeModes?: SkillDisplayConfig["mode"][];
|
||||
} = {},
|
||||
): RuntimeDisplaySource[] {
|
||||
const start =
|
||||
options.afterMessageId === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.afterMessageId) + 1,
|
||||
0,
|
||||
);
|
||||
const limit =
|
||||
options.beforeMessageId === undefined
|
||||
? messages.length
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId),
|
||||
0,
|
||||
);
|
||||
const allowedModes = options.includeModes
|
||||
? new Set(options.includeModes)
|
||||
: null;
|
||||
const sources: RuntimeDisplaySource[] = [];
|
||||
let citationIndex = 0;
|
||||
const displayIndexes = new Map<string, number>();
|
||||
|
||||
for (let index = start; index < limit; index++) {
|
||||
const message = messages[index];
|
||||
if (!message || message.type !== "ai") {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const match = findDisplayConfigForTool(
|
||||
toolCall.name,
|
||||
displays,
|
||||
toolCall.args as Record<string, unknown> | undefined,
|
||||
);
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
}
|
||||
const result = findToolResultPayload(toolCall.id, messages);
|
||||
if (result === undefined) {
|
||||
continue;
|
||||
}
|
||||
const normalized = match
|
||||
? normalizeDisplaySources(result, match.display)
|
||||
: normalizeDefaultDisplaySources(result);
|
||||
for (const item of normalized) {
|
||||
const skillName = match?.skillName ?? toolCall.name;
|
||||
const displayKey = `${skillName}:${effectiveMode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
sources.push({
|
||||
...item,
|
||||
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
|
||||
skillName,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
|
||||
const normalized = normalizeSearchResults(
|
||||
typeof payload === "string" || isRecord(payload)
|
||||
? (payload as string | Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return normalized.map((item, index) => ({
|
||||
id: item.url ?? String(index + 1),
|
||||
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
||||
type: "result",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content: item.content ?? item.snippet,
|
||||
source: item.source,
|
||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||
url: item.url,
|
||||
raw: item as unknown as Record<string, unknown>,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyInlineCitations(
|
||||
content: string,
|
||||
sources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
if (!content || sources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const citationSources = sources.filter((source) => source.mode === "citation");
|
||||
if (citationSources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
|
||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||
for (const source of citationSources) {
|
||||
if (source.url) {
|
||||
byUrl.set(source.url, source);
|
||||
}
|
||||
if (source.title) {
|
||||
byTitle.set(source.title.trim(), source);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
let cursor = 0;
|
||||
let next = "";
|
||||
for (const match of normalized.matchAll(markdownLinkPattern)) {
|
||||
const start = match.index ?? 0;
|
||||
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
|
||||
next += match[0];
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
|
||||
return next;
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
): string {
|
||||
return content
|
||||
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
||||
citationMarkdown(match, Number(rawIndex), byIndex),
|
||||
)
|
||||
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
|
||||
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeExistingCitationLinks(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
byUrl: Map<string, RuntimeDisplaySource>,
|
||||
byTitle: Map<string, RuntimeDisplaySource>,
|
||||
): string {
|
||||
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
|
||||
// ("[1]", "1") or a free-form title — to a numbered reference link that
|
||||
// CitationLink renders as a badge and scrolls to the right-side panel.
|
||||
return content.replace(
|
||||
/\[citation:([^\]]+)\]\(([^)]+)\)(?:\([^)]+\))*/g,
|
||||
(match, rawLabel: string, rawUrl: string) => {
|
||||
const label = rawLabel.trim();
|
||||
const url = rawUrl.trim();
|
||||
const numberMatch = label.match(/^\[?(\d+)\]?$/);
|
||||
if (numberMatch) {
|
||||
const index = Number(numberMatch[1]);
|
||||
if (byIndex.has(index)) {
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
}
|
||||
}
|
||||
const sourceByUrl = byUrl.get(url);
|
||||
if (sourceByUrl) {
|
||||
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
|
||||
}
|
||||
const sourceByTitle = byTitle.get(label);
|
||||
if (sourceByTitle) {
|
||||
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function citationMarkdown(
|
||||
fallback: string,
|
||||
index: number,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
): string {
|
||||
const source = byIndex.get(index);
|
||||
if (!source) {
|
||||
return fallback;
|
||||
}
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function findToolResultPayload(
|
||||
toolCallId: string | undefined,
|
||||
messages: Message[],
|
||||
): unknown {
|
||||
if (!toolCallId) {
|
||||
return undefined;
|
||||
}
|
||||
const toolMessage = messages.find(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
if (!toolMessage) {
|
||||
return undefined;
|
||||
}
|
||||
const content = extractToolText(toolMessage.content);
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
// bash stdout on the local sandbox can be suffixed with
|
||||
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
|
||||
// and skills sometimes print informational lines around their JSON payload.
|
||||
// Salvage the first balanced JSON object or array we can find before
|
||||
// falling back to the raw string.
|
||||
const recovered = extractJSONFromText(content);
|
||||
if (recovered !== undefined) {
|
||||
return recovered;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJSONFromText(text: string): unknown {
|
||||
const candidates: Array<[number, number]> = [];
|
||||
const firstObj = text.indexOf("{");
|
||||
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
|
||||
const firstArr = text.indexOf("[");
|
||||
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
|
||||
candidates.sort(([a], [b]) => a - b);
|
||||
for (const [start, end] of candidates) {
|
||||
if (start < 0 || end <= start) continue;
|
||||
const slice = text.slice(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractToolText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === "object" && "text" in part) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
Reference in new issue
Block a user