技能展示适配持久化 + 参考文献收集与新对话流式修复
- 后端新增 skill_display_adapters 表,持久化每个技能的 display 配置与 sample
payload;/api/skills 列表、详情、设置接口同步读写
- 新增 /api/skills/{name}/display-sample 端点,编辑器优先加载库中样例
- 前端展示适配面板改为从后端加载持久样例,无样例时提示"自动识别"
- 参考文献收集修复:仅在 bash/shell/python 等执行类工具上从参数推断技能
名,避免 read_file / web_fetch 误匹配;引用列表纳入最新一条助手消息
- ChatBox 改用「最新 human → 最新 ai」窗口收集引用,并仅保留 citation
模式
- 修复「新对话」点击后已发消息消失:useThreadStream 只在 threadId 切回
空值时重置/中止 SSE,避免 onCreated 分配 id 时把刚开始的流打断;
ChatPage 在 landing 状态隐藏 MessageList,移除 KeyedChatPage remount
- 技能详情对话框:所有者可查看源码与文件 tab(不再要求管理员)
- LocalSandbox:Windows PowerShell 5 路径下把 && 重写为 ;,并以
UTF-8 解码 subprocess 输出,避免中文乱码
- knowledge-search-v2 mock 样例改为中文固定测试数据
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4277f8566
commit
ad5d55220c
17 files changed
+2723
-2134
No files matched your search
@@ -27,50 +27,65 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
export const mockKnowledgeSearchV2Result = {
|
||||
success: true,
|
||||
query: "latest news",
|
||||
count: 3,
|
||||
query: "参考文献模式测试 知识库固定测试",
|
||||
count: 4,
|
||||
source: "fixed-display-sample",
|
||||
results: [
|
||||
{
|
||||
index: 1,
|
||||
recUuid: "rec-001",
|
||||
recUuid: "ksv2-fixed-001",
|
||||
relevance_score: 0.982,
|
||||
title: "Knowledge result one",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
title: "参考文献模式测试:参考文献模式固定测试数据一",
|
||||
source_category: "知识库/测试资料",
|
||||
source: "knowledge-search-v2/fixed/source-001",
|
||||
publish_time: "2026-05-25",
|
||||
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
||||
content_length: 1200,
|
||||
content_preview:
|
||||
"这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
|
||||
content_length: 168,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
recUuid: "rec-002",
|
||||
relevance_score: 0.951,
|
||||
title: "Knowledge result two",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
recUuid: "ksv2-fixed-002",
|
||||
relevance_score: 0.936,
|
||||
title: "参考文献模式测试:结构化字段映射验证",
|
||||
source_category: "知识库/字段映射",
|
||||
source: "knowledge-search-v2/fixed/source-002",
|
||||
publish_time: "2026-05-24",
|
||||
content_preview: "Mock result used to verify citation ordering and detail preview.",
|
||||
content_length: 980,
|
||||
content_preview:
|
||||
"本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
|
||||
content_length: 154,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
recUuid: "rec-003",
|
||||
relevance_score: 0.921,
|
||||
title: "Knowledge result three",
|
||||
source_category: "knowledge/commentary",
|
||||
source: "internal knowledge library",
|
||||
recUuid: "ksv2-fixed-003",
|
||||
relevance_score: 0.887,
|
||||
title: "参考文献模式测试:多引用排序验证",
|
||||
source_category: "知识库/排序验证",
|
||||
source: "knowledge-search-v2/fixed/source-003",
|
||||
publish_time: "2026-05-23",
|
||||
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
|
||||
content_length: 760,
|
||||
content_preview:
|
||||
"本条用于验证多条参考文献的编号顺序是否与 results 数组顺序一致,并验证点击正文编号时能定位到右侧对应参考文献。",
|
||||
content_length: 132,
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
recUuid: "",
|
||||
relevance_score: 0.801,
|
||||
title: "参考文献模式测试:无 recUuid 跳转隐藏验证",
|
||||
source_category: "知识库/边界场景",
|
||||
source: "knowledge-search-v2/fixed/source-004",
|
||||
publish_time: "2026-05-22",
|
||||
content_preview: "本条故意把 recUuid 置为空,用于验证右侧弹框仍展示详情,但不展示外部跳转按钮。",
|
||||
content_length: 98,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -196,7 +211,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
|
||||
.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}`,
|
||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${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,
|
||||
|
||||
@@ -56,7 +56,7 @@ export function findDisplayConfigForTool(
|
||||
// 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) {
|
||||
if (toolArgs && canInferSkillFromToolArgs(toolName)) {
|
||||
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
||||
if (skillFromArgs) {
|
||||
const direct = displays.get(skillFromArgs);
|
||||
@@ -69,6 +69,51 @@ export function findDisplayConfigForTool(
|
||||
}
|
||||
|
||||
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
||||
const TOOL_ARG_SKILL_MATCH_EXCLUDES = new Set([
|
||||
"read_file",
|
||||
"readfile",
|
||||
"ls",
|
||||
"write_file",
|
||||
"writefile",
|
||||
"str_replace",
|
||||
"strreplace",
|
||||
"web_fetch",
|
||||
"webfetch",
|
||||
]);
|
||||
|
||||
function canInferSkillFromToolArgs(toolName: string): boolean {
|
||||
const normalized = normalizeName(toolName);
|
||||
if (
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.includes("bash") ||
|
||||
normalized.includes("shell") ||
|
||||
normalized.includes("python") ||
|
||||
normalized.includes("execute") ||
|
||||
normalized.includes("terminal")
|
||||
);
|
||||
}
|
||||
|
||||
function canInspectPayloadForDisplay(toolName: string): boolean {
|
||||
const normalized = normalizeName(toolName);
|
||||
if (
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(toolName) ||
|
||||
TOOL_ARG_SKILL_MATCH_EXCLUDES.has(normalized)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
canInferSkillFromToolArgs(toolName) ||
|
||||
normalized.includes("search") ||
|
||||
normalized.includes("retriev") ||
|
||||
normalized.includes("knowledge") ||
|
||||
normalized.includes("query")
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
@@ -87,6 +132,7 @@ export function collectRuntimeDisplaySources(
|
||||
options: {
|
||||
afterMessageId?: string;
|
||||
beforeMessageId?: string;
|
||||
includeBeforeMessage?: boolean;
|
||||
includeModes?: SkillDisplayConfig["mode"][];
|
||||
} = {},
|
||||
): RuntimeDisplaySource[] {
|
||||
@@ -101,7 +147,8 @@ export function collectRuntimeDisplaySources(
|
||||
options.beforeMessageId === undefined
|
||||
? messages.length
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId),
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId) +
|
||||
(options.includeBeforeMessage ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const allowedModes = options.includeModes
|
||||
@@ -117,22 +164,38 @@ export function collectRuntimeDisplaySources(
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const match = findDisplayConfigForTool(
|
||||
const toolMatch = 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;
|
||||
}
|
||||
if (isDisplaySamplePayload(result)) {
|
||||
continue;
|
||||
}
|
||||
const match =
|
||||
toolMatch ??
|
||||
findDisplayConfigForPayload(
|
||||
toolCall.name,
|
||||
result,
|
||||
displays,
|
||||
);
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = match
|
||||
? normalizeDisplaySources(result, match.display)
|
||||
: normalizeDefaultDisplaySources(result);
|
||||
: shouldUseDefaultCitationMode(
|
||||
toolCall.name,
|
||||
result,
|
||||
toolCall.args as Record<string, unknown> | undefined,
|
||||
)
|
||||
? normalizeDefaultDisplaySources(result)
|
||||
: [];
|
||||
for (const item of normalized) {
|
||||
const skillName = match?.skillName ?? toolCall.name;
|
||||
const displayKey = `${skillName}:${effectiveMode}`;
|
||||
@@ -151,6 +214,113 @@ export function collectRuntimeDisplaySources(
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function findDisplayConfigForPayload(
|
||||
toolName: string,
|
||||
payload: unknown,
|
||||
displays: SkillDisplayMap,
|
||||
): { skillName: string; display: SkillDisplayConfig } | null {
|
||||
if (!canInspectPayloadForDisplay(toolName)) {
|
||||
return null;
|
||||
}
|
||||
let best: { skillName: string; display: SkillDisplayConfig; score: number } | null = null;
|
||||
for (const [skillName, display] of displays) {
|
||||
const score = scoreDisplayPayloadMatch(payload, display);
|
||||
if (score >= 3 && (!best || score > best.score)) {
|
||||
best = { skillName, display, score };
|
||||
}
|
||||
}
|
||||
return best ? { skillName: best.skillName, display: best.display } : null;
|
||||
}
|
||||
|
||||
export function isDisplaySamplePayload(payload: unknown): boolean {
|
||||
if (!isRecord(payload)) {
|
||||
return false;
|
||||
}
|
||||
if (payload.__displaySample === true || payload.displaySample === true) {
|
||||
return true;
|
||||
}
|
||||
if (payload.query === "sample query") {
|
||||
return true;
|
||||
}
|
||||
const items =
|
||||
getArrayByPath(payload, "results") ??
|
||||
getArrayByPath(payload, "data") ??
|
||||
getArrayByPath(payload, "items");
|
||||
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
||||
if (!first) {
|
||||
return false;
|
||||
}
|
||||
const text = Object.values(first)
|
||||
.filter((value) => typeof value === "string")
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return (
|
||||
/^sample-/i.test(String(first.id ?? "")) ||
|
||||
text.includes("mock result") ||
|
||||
text.includes("knowledge result one")
|
||||
);
|
||||
}
|
||||
|
||||
function scoreDisplayPayloadMatch(payload: unknown, display: SkillDisplayConfig): number {
|
||||
const items = getArrayByPath(payload, display.resultPath);
|
||||
const first = items?.find((item): item is Record<string, unknown> => isRecord(item));
|
||||
if (!first) {
|
||||
return 0;
|
||||
}
|
||||
const fields = [
|
||||
display.titleField,
|
||||
display.summaryField,
|
||||
display.contentField,
|
||||
display.sourceField,
|
||||
display.typeField,
|
||||
display.timeField,
|
||||
display.scoreField,
|
||||
display.urlField,
|
||||
display.idField,
|
||||
].filter((field): field is string => !!field);
|
||||
return new Set(fields).size === 0
|
||||
? 0
|
||||
: [...new Set(fields)].filter(
|
||||
(field) =>
|
||||
first[field] !== undefined &&
|
||||
first[field] !== null &&
|
||||
first[field] !== "",
|
||||
).length;
|
||||
}
|
||||
|
||||
function getArrayByPath(payload: unknown, path: string): unknown[] | null {
|
||||
const value = path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, payload);
|
||||
return Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
export function shouldUseDefaultCitationMode(
|
||||
toolName: string,
|
||||
payload: unknown,
|
||||
toolArgs?: Record<string, unknown>,
|
||||
): boolean {
|
||||
const normalizedName = normalizeName(toolName);
|
||||
const isSearchLikeTool =
|
||||
normalizedName.includes("search") ||
|
||||
normalizedName.includes("retriev") ||
|
||||
normalizedName.includes("knowledge") ||
|
||||
normalizedName.includes("query");
|
||||
const isSkillInvocation = !!(
|
||||
toolArgs &&
|
||||
canInferSkillFromToolArgs(toolName) &&
|
||||
extractSkillNameFromArgs(toolArgs)
|
||||
);
|
||||
if (!isSearchLikeTool && !isSkillInvocation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizeDefaultDisplaySources(payload).length > 0;
|
||||
}
|
||||
|
||||
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
|
||||
const normalized = normalizeSearchResults(
|
||||
typeof payload === "string" || isRecord(payload)
|
||||
@@ -288,23 +458,77 @@ function findToolResultPayload(
|
||||
if (!toolMessage) {
|
||||
return undefined;
|
||||
}
|
||||
const structured = extractStructuredToolPayload(toolMessage.content);
|
||||
if (structured !== undefined) {
|
||||
return structured;
|
||||
}
|
||||
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;
|
||||
const parsed = parseJSONLikeText(content);
|
||||
if (parsed !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function extractStructuredToolPayload(content: unknown): unknown {
|
||||
if (isRecord(content)) {
|
||||
return unwrapToolPayloadObject(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (!isRecord(part)) {
|
||||
continue;
|
||||
}
|
||||
if (isRecord(part.json)) {
|
||||
return unwrapToolPayloadObject(part.json);
|
||||
}
|
||||
const unwrapped = unwrapToolPayloadObject(part);
|
||||
if (unwrapped !== undefined) {
|
||||
return unwrapped;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unwrapToolPayloadObject(value: Record<string, unknown>): unknown {
|
||||
if (
|
||||
Array.isArray(value.results) ||
|
||||
Array.isArray(value.data) ||
|
||||
Array.isArray(value.items)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
for (const key of ["result", "output", "stdout", "stderr", "content", "text"]) {
|
||||
const nested = value[key];
|
||||
if (typeof nested === "string") {
|
||||
const parsed = parseJSONLikeText(nested);
|
||||
if (parsed !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
if (isRecord(nested)) {
|
||||
const unwrapped = unwrapToolPayloadObject(nested);
|
||||
if (unwrapped !== undefined) {
|
||||
return unwrapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJSONLikeText(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return extractJSONFromText(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user