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

- 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 authored and xuhd committed 2026-05-28 18:28:41 +08:00
1 parent 3734b6a05f
commit 61babbdf7d
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 ?? []),
[thread.values.messages],
);
const latestAssistant = useMemo(
() => findLatestAssistantMessageAfter(thread.values.messages ?? [], latestHumanIndex),
[latestHumanIndex, thread.values.messages],
);
const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) {
return [];
}
const messages = thread.values.messages ?? [];
if (!latestAssistant?.id) {
return [];
}
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined,
beforeMessageId: latestAssistant.id,
includeBeforeMessage: true,
includeModes: ["citation"],
});
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, latestHumanIndex, thread.values.messages]);
}, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]);
const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"),
[displaySources],
@@ -93,7 +84,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
if (hasCitationReferences) {
setReferencesOpen(true);
}
}, [hasCitationReferences, latestAssistant?.id]);
}, [hasCitationReferences]);
useEffect(() => {
const handleShowReference = () => {
setReferencesOpen(true);
@@ -333,17 +324,4 @@ function findLatestHumanMessageIndex(messages: Message[]) {
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 };
@@ -34,62 +34,6 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
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[]> = {
titleField: ["title", "m_title", "name", "subject", "headline"],
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"],
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
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 {
if (!path) return value;
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;
}, 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")) {
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.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score";
inferred.idField = inferred.idField || "recUuid";
inferred.linkStrategy = "id";
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
}
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
return inferred;
}
@@ -192,14 +141,18 @@ function stringify(value: unknown, fallback = ""): 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 {
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;
if (config.linkStrategy === "id") {
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : 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,
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,
score:
config.scoreField && typeof item[config.scoreField] === "number"
? (item[config.scoreField] as number)
: undefined,
url: resolveLink(item, config),
raw: item,
}));
+160 -11
View File
@@ -17,6 +17,12 @@ export type RuntimeDisplaySource = DisplaySourceItem & {
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
type RuntimeToolCall = {
id?: string;
name: string;
args?: Record<string, unknown>;
};
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
const map = new Map<string, SkillDisplayConfig>();
for (const skill of skills) {
@@ -163,13 +169,13 @@ export function collectRuntimeDisplaySources(
if (!message || message.type !== "ai") {
continue;
}
for (const toolCall of message.tool_calls ?? []) {
for (const toolCall of extractRuntimeToolCalls(message)) {
const toolMatch = findDisplayConfigForTool(
toolCall.name,
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) {
continue;
}
@@ -192,7 +198,7 @@ export function collectRuntimeDisplaySources(
: shouldUseDefaultCitationMode(
toolCall.name,
result,
toolCall.args as Record<string, unknown> | undefined,
toolCall.args,
)
? normalizeDefaultDisplaySources(result)
: [];
@@ -214,6 +220,80 @@ export function collectRuntimeDisplaySources(
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(
toolName: string,
payload: unknown,
@@ -445,16 +525,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function stringifyMaybe(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function findToolResultPayload(
toolCallId: string | undefined,
toolCall: RuntimeToolCall,
messages: Message[],
toolCallMessageIndex = -1,
): unknown {
if (!toolCallId) {
return undefined;
}
const toolMessage = messages.find(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
const toolMessage =
findToolMessageById(toolCall.id, messages) ??
findToolMessageByName(toolCall, messages, toolCallMessageIndex);
if (!toolMessage) {
return undefined;
}
@@ -473,6 +555,73 @@ function findToolResultPayload(
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 {
if (isRecord(content)) {
return unwrapToolPayloadObject(content);
@@ -2,7 +2,10 @@ import asyncio
import json
import logging
import mimetypes
import os
import re
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
@@ -412,6 +415,102 @@ def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | 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]:
"""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
be parsed as a JSON object.
"""
script_sample = await _run_skill_python_sample(skill)
if script_sample:
return script_sample
try:
skill_content = skill.skill_file.read_text(encoding="utf-8")
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)
except Exception:
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]:
@@ -1883,9 +2002,6 @@ async def get_skill_display_sample(
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
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)
await _upsert_skill_display_adapter(skill_name, sample=sample)
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)