技能展示样本运行器 + 参考文献来源过滤调整

- skills 路由新增 _run_skill_python_sample,按本地 Python 候选实际执行
  技能脚本,作为展示适配器的真实样本来源
- skill-display 运行时与适配器调整,参考文献收集放宽起始消息约束

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-26 14:24:47 +08:00
1 parent 26a388e3bf
commit d3d7db1f34
4 files changed
+303 -104

No files matched your search

@@ -65,25 +65,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
() => findLatestHumanMessageIndex(thread.values.messages ?? []), () => findLatestHumanMessageIndex(thread.values.messages ?? []),
[thread.values.messages], [thread.values.messages],
); );
const latestAssistant = useMemo(
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
[latestHumanIndex, thread.values.messages],
);
const displaySources = useMemo(() => { const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) { if (!settings.display.ragModeEnabled) {
return []; return [];
} }
const messages = thread.values.messages ?? []; const messages = thread.values.messages ?? [];
if (!latestAssistant?.id) {
return [];
}
return collectRuntimeDisplaySources(messages, skillDisplayMap, { return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined, afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
beforeMessageId: latestAssistant.id,
includeBeforeMessage: true,
includeModes: ["citation"], includeModes: ["citation"],
}); });
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]); }, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]);
const referenceSources = useMemo( const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"), () => displaySources.filter((source) => source.mode === "citation"),
[displaySources], [displaySources],
@@ -93,7 +84,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
if (hasCitationReferences) { if (hasCitationReferences) {
setReferencesOpen(true); setReferencesOpen(true);
} }
}, [hasCitationReferences, latestAssistant?.id]); }, [hasCitationReferences]);
useEffect(() => { useEffect(() => {
const handleShowReference = () => { const handleShowReference = () => {
setReferencesOpen(true); setReferencesOpen(true);
@@ -333,17 +324,4 @@ function findLatestHumanMessageIndex(messages: Message[]) {
return -1; return -1;
} }
function findLatestAssistantMessageAfter(messages: Message[], afterIndex: number) {
for (let index = messages.length - 1; index >= 0; index--) {
if (afterIndex >= 0 && index <= afterIndex) {
return null;
}
const message = messages[index];
if (message?.type === "ai") {
return message;
}
}
return null;
}
export { ChatBox }; export { ChatBox };
@@ -34,62 +34,6 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
tableColumns: [], tableColumns: [],
}); });
export const mockKnowledgeSearchV2Result = {
success: true,
query: "参考文献模式测试 知识库固定测试",
count: 4,
source: "fixed-display-sample",
results: [
{
index: 1,
recUuid: "ksv2-fixed-001",
relevance_score: 0.982,
title: "参考文献模式测试:参考文献模式固定测试数据一",
source_category: "知识库/测试资料",
source: "knowledge-search-v2/fixed/source-001",
publish_time: "2026-05-25",
content_preview:
"这是 knowledge-search-v2 返回的第一条固定测试资料,用于验证右侧参考文献列表、正文编号以及 recUuid 模板跳转是否正常。",
content_length: 168,
},
{
index: 2,
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:
"本条用于验证 title、source_category、source、publish_time、content_preview、relevance_score、recUuid 等字段能被前端正确识别并渲染。",
content_length: 154,
},
{
index: 3,
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:
"本条用于验证多条参考文献的编号顺序是否与 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,
},
],
};
const candidateFields: Record<string, string[]> = { const candidateFields: Record<string, string[]> = {
titleField: ["title", "m_title", "name", "subject", "headline"], titleField: ["title", "m_title", "name", "subject", "headline"],
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"], summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
@@ -99,18 +43,23 @@ const candidateFields: Record<string, string[]> = {
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"], timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
scoreField: ["relevance_score", "rerank_score", "score", "similarity"], scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
urlField: ["url", "link", "href", "source_url"], urlField: ["url", "link", "href", "source_url"],
idField: ["recUuid", "id", "uuid", "docId", "taskId", "recordId"], idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
}; };
function getByPath(value: unknown, path: string): unknown { function getByPath(value: unknown, path: string): unknown {
if (!path) return value; if (!path) return value;
return path.split(".").reduce<unknown>((acc, key) => { return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[key]; if (acc && typeof acc === "object") {
return (acc as Record<string, unknown>)[key];
}
return undefined; return undefined;
}, value); }, value);
} }
function findFirstArray(value: unknown, path = ""): { path: string; items: Record<string, unknown>[] } | null { function findFirstArray(
value: unknown,
path = "",
): { path: string; items: Record<string, unknown>[] } | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) { if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
return { path, items: value as Record<string, unknown>[] }; return { path, items: value as Record<string, unknown>[] };
} }
@@ -157,12 +106,12 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
inferred.typeField = inferred.typeField || "source_category"; inferred.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time"; inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score"; inferred.scoreField = inferred.scoreField || "relevance_score";
inferred.idField = inferred.idField || "recUuid"; inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
inferred.linkStrategy = "id"; inferred.linkStrategy = inferred.urlField ? "auto" : "id";
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}"; inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
} }
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
return inferred; return inferred;
} }
@@ -192,14 +141,18 @@ function stringify(value: unknown, fallback = ""): string {
} }
function renderTemplate(template: string, item: Record<string, unknown>): string { function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => encodeURIComponent(stringify(item[key]))); return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
encodeURIComponent(stringify(item[key])),
);
} }
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined { function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
const url = config.urlField ? stringify(item[config.urlField]) : ""; const url = config.urlField ? stringify(item[config.urlField]) : "";
const id = config.idField ? stringify(item[config.idField]) : ""; const id = config.idField ? stringify(item[config.idField]) : "";
if (config.linkStrategy === "url") return url || undefined; if (config.linkStrategy === "url") return url || undefined;
if (config.linkStrategy === "id") return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : 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; return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
} }
@@ -217,7 +170,10 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
content: config.contentField ? stringify(item[config.contentField]) : undefined, content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined, source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined, time: config.timeField ? stringify(item[config.timeField]) : undefined,
score: config.scoreField && typeof item[config.scoreField] === "number" ? item[config.scoreField] as number : undefined, score:
config.scoreField && typeof item[config.scoreField] === "number"
? (item[config.scoreField] as number)
: undefined,
url: resolveLink(item, config), url: resolveLink(item, config),
raw: item, raw: item,
})); }));
+160 -11
View File
@@ -17,6 +17,12 @@ export type RuntimeDisplaySource = DisplaySourceItem & {
export type SkillDisplayMap = Map<string, SkillDisplayConfig>; export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
type RuntimeToolCall = {
id?: string;
name: string;
args?: Record<string, unknown>;
};
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap { export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
const map = new Map<string, SkillDisplayConfig>(); const map = new Map<string, SkillDisplayConfig>();
for (const skill of skills) { for (const skill of skills) {
@@ -163,13 +169,13 @@ export function collectRuntimeDisplaySources(
if (!message || message.type !== "ai") { if (!message || message.type !== "ai") {
continue; continue;
} }
for (const toolCall of message.tool_calls ?? []) { for (const toolCall of extractRuntimeToolCalls(message)) {
const toolMatch = findDisplayConfigForTool( const toolMatch = findDisplayConfigForTool(
toolCall.name, toolCall.name,
displays, displays,
toolCall.args as Record<string, unknown> | undefined, toolCall.args,
); );
const result = findToolResultPayload(toolCall.id, messages); const result = findToolResultPayload(toolCall, messages, index);
if (result === undefined) { if (result === undefined) {
continue; continue;
} }
@@ -192,7 +198,7 @@ export function collectRuntimeDisplaySources(
: shouldUseDefaultCitationMode( : shouldUseDefaultCitationMode(
toolCall.name, toolCall.name,
result, result,
toolCall.args as Record<string, unknown> | undefined, toolCall.args,
) )
? normalizeDefaultDisplaySources(result) ? normalizeDefaultDisplaySources(result)
: []; : [];
@@ -214,6 +220,80 @@ export function collectRuntimeDisplaySources(
return sources; return sources;
} }
function extractRuntimeToolCalls(message: Message): RuntimeToolCall[] {
const calls: RuntimeToolCall[] = [];
const direct = (message as { tool_calls?: unknown }).tool_calls;
if (Array.isArray(direct)) {
for (const raw of direct) {
const call = normalizeRuntimeToolCall(raw);
if (call) {
calls.push(call);
}
}
}
const kwargs = (message as { additional_kwargs?: Record<string, unknown> })
.additional_kwargs;
const extra = kwargs?.tool_calls;
if (Array.isArray(extra)) {
for (const raw of extra) {
const call = normalizeRuntimeToolCall(raw);
if (call && !calls.some((item) => item.id && item.id === call.id)) {
calls.push(call);
}
}
}
const chunks = (message as { tool_call_chunks?: unknown }).tool_call_chunks;
if (Array.isArray(chunks)) {
for (const raw of chunks) {
const call = normalizeRuntimeToolCall(raw);
if (call && !calls.some((item) => item.id && item.id === call.id)) {
calls.push(call);
}
}
}
return calls;
}
function normalizeRuntimeToolCall(raw: unknown): RuntimeToolCall | null {
if (!isRecord(raw)) {
return null;
}
const fn = isRecord(raw.function) ? raw.function : null;
const name =
stringifyMaybe(raw.name) ??
stringifyMaybe(fn?.name) ??
stringifyMaybe(raw.tool_name);
if (!name) {
return null;
}
return {
id:
stringifyMaybe(raw.id) ??
stringifyMaybe(raw.tool_call_id) ??
stringifyMaybe(raw.call_id),
name,
args: normalizeToolArgs(raw.args ?? raw.arguments ?? fn?.arguments),
};
}
function normalizeToolArgs(value: unknown): Record<string, unknown> | undefined {
if (isRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return isRecord(parsed) ? parsed : undefined;
} catch {
return { command: value };
}
}
return undefined;
}
export function findDisplayConfigForPayload( export function findDisplayConfigForPayload(
toolName: string, toolName: string,
payload: unknown, payload: unknown,
@@ -445,16 +525,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
} }
function stringifyMaybe(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function findToolResultPayload( function findToolResultPayload(
toolCallId: string | undefined, toolCall: RuntimeToolCall,
messages: Message[], messages: Message[],
toolCallMessageIndex = -1,
): unknown { ): unknown {
if (!toolCallId) { const toolMessage =
return undefined; findToolMessageById(toolCall.id, messages) ??
} findToolMessageByName(toolCall, messages, toolCallMessageIndex);
const toolMessage = messages.find(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
if (!toolMessage) { if (!toolMessage) {
return undefined; return undefined;
} }
@@ -473,6 +555,73 @@ function findToolResultPayload(
return content; return content;
} }
function findToolMessageById(
toolCallId: string | undefined,
messages: Message[],
): Message | undefined {
if (!toolCallId) {
return undefined;
}
return messages.find((message) => {
if (message.type !== "tool") {
return false;
}
const candidates = [
(message as { tool_call_id?: unknown }).tool_call_id,
(message as { id?: unknown }).id,
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
?.tool_call_id,
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
?.id,
];
return candidates.some((candidate) => candidate === toolCallId);
});
}
function findToolMessageByName(
toolCall: RuntimeToolCall,
messages: Message[],
toolCallMessageIndex: number,
): Message | undefined {
const start = Math.max(toolCallMessageIndex + 1, 0);
for (let index = start; index < messages.length; index++) {
const message = messages[index];
if (!message) {
continue;
}
if (index > start && (message.type === "human" || message.type === "ai")) {
break;
}
if (message.type !== "tool") {
continue;
}
const name =
stringifyMaybe((message as { name?: unknown }).name) ??
stringifyMaybe(
(message as { additional_kwargs?: Record<string, unknown> }).additional_kwargs
?.name,
);
const messageToolCallId = stringifyMaybe(
(message as { tool_call_id?: unknown }).tool_call_id,
);
if (messageToolCallId && toolCall.id && messageToolCallId !== toolCall.id) {
continue;
}
if (name && normalizeName(name) !== normalizeName(toolCall.name)) {
continue;
}
const payload = extractStructuredToolPayload(message.content);
if (payload !== undefined) {
return message;
}
const text = extractToolText(message.content);
if (text && parseJSONLikeText(text) !== undefined) {
return message;
}
}
return undefined;
}
function extractStructuredToolPayload(content: unknown): unknown { function extractStructuredToolPayload(content: unknown): unknown {
if (isRecord(content)) { if (isRecord(content)) {
return unwrapToolPayloadObject(content); return unwrapToolPayloadObject(content);
@@ -2,7 +2,10 @@ import asyncio
import json import json
import logging import logging
import mimetypes import mimetypes
import os
import re import re
import subprocess
import sys
import tempfile import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -412,6 +415,102 @@ def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None:
return None return None
def _skill_sample_python_candidates() -> list[str]:
candidates = [
os.environ.get("DEERFLOW_SKILL_SAMPLE_PYTHON"),
r"C:\Users\ji\anaconda3\envs\open_manus\python.exe",
sys.executable,
"python",
]
result: list[str] = []
for candidate in candidates:
if not candidate or candidate in result:
continue
result.append(candidate)
return result
def _find_skill_sample_script(skill: Skill) -> Path | None:
skill_dir = skill.skill_file.parent
preferred = [
skill_dir / "scripts" / "search.py",
skill_dir / "search.py",
skill_dir / "scripts" / "run.py",
skill_dir / "run.py",
]
for path in preferred:
if path.exists() and path.is_file():
return path
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists():
for path in sorted(scripts_dir.glob("*.py")):
if path.is_file():
return path
return None
async def _run_skill_python_sample(skill: Skill) -> dict[str, Any] | None:
"""Run a skill's Python script to capture a real JSON sample.
This is used by the display adapter editor. It intentionally runs the same
script the agent would run, instead of guessing from SKILL.md examples.
"""
script = _find_skill_sample_script(skill)
if script is None:
return None
query = os.environ.get("DEERFLOW_SKILL_SAMPLE_QUERY") or "参考文献模式测试"
env = os.environ.copy()
env.setdefault("PYTHONIOENCODING", "utf-8")
env.setdefault("DEERFLOW_MOCK_KNOWLEDGE_SEARCH", "1")
env.setdefault("KNOWLEDGE_SEARCH_V2_FIXED", "1")
for python_exe in _skill_sample_python_candidates():
try:
completed = await asyncio.to_thread(
subprocess.run,
[python_exe, str(script), query],
cwd=str(script.parent),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
logger.warning("Skill sample runner failed for %s via %s: %s", skill.name, python_exe, exc)
continue
except Exception:
logger.warning("Skill sample runner crashed for %s via %s", skill.name, python_exe, exc_info=True)
continue
out_text = (completed.stdout or "").strip()
err_text = (completed.stderr or "").strip()
if completed.returncode != 0:
logger.warning(
"Skill sample script exited %s for %s via %s: %s",
completed.returncode,
skill.name,
python_exe,
err_text[-1000:],
)
continue
parsed = _extract_json_from_llm_output(out_text)
if parsed:
parsed.setdefault("__sampleSource", "python-script")
parsed.setdefault("__sampleScript", str(script))
return parsed
logger.warning(
"Skill sample script returned non-JSON for %s via %s: stdout=%s stderr=%s",
skill.name,
python_exe,
out_text[-1000:],
err_text[-1000:],
)
return None
async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]: async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]:
"""Generate a realistic return-shape sample for ``skill`` via the LLM. """Generate a realistic return-shape sample for ``skill`` via the LLM.
@@ -420,6 +519,10 @@ async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) ->
when the model is unconfigured, the call times out, or the output cannot when the model is unconfigured, the call times out, or the output cannot
be parsed as a JSON object. be parsed as a JSON object.
""" """
script_sample = await _run_skill_python_sample(skill)
if script_sample:
return script_sample
try: try:
skill_content = skill.skill_file.read_text(encoding="utf-8") skill_content = skill.skill_file.read_text(encoding="utf-8")
except OSError as exc: except OSError as exc:
@@ -461,7 +564,23 @@ async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) ->
logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name) logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name)
except Exception: except Exception:
logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True) logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True)
return _mock_skill_display_sample(skill.name) return _generic_skill_display_sample(skill.name)
def _generic_skill_display_sample(skill_name: str) -> dict[str, Any]:
return {
"success": True,
"query": "sample query",
"results": [
{
"id": f"{skill_name}-sample-001",
"title": "Sample result",
"summary": "Sample structured output used to infer field mapping.",
"source": skill_name,
"updatedAt": "2026-05-25",
}
],
}
def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]: def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
@@ -1883,9 +2002,6 @@ async def get_skill_display_sample(
skill = next((s for s in skills if s.name == skill_name), None) skill = next((s for s in skills if s.name == skill_name), None)
if skill is None: if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found") raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
adapter = await _get_skill_display_adapter(skill_name)
if adapter and isinstance(adapter.get("sample"), dict):
return SkillDisplaySampleResponse(skill_name=skill_name, sample=adapter["sample"])
sample = await _generate_skill_display_sample(skill, config) sample = await _generate_skill_display_sample(skill, config)
await _upsert_skill_display_adapter(skill_name, sample=sample) await _upsert_skill_display_adapter(skill_name, sample=sample)
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample) return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)