From 61babbdf7d5a9645c9d397b2ed74a5947a973b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 26 May 2026 14:24:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=80=E8=83=BD=E5=B1=95=E7=A4=BA=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=E8=BF=90=E8=A1=8C=E5=99=A8=20+=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E7=8C=AE=E6=9D=A5=E6=BA=90=E8=BF=87=E6=BB=A4=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills 路由新增 _run_skill_python_sample,按本地 Python 候选实际执行 技能脚本,作为展示适配器的真实样本来源 - skill-display 运行时与适配器调整,参考文献收集放宽起始消息约束 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/workspace/chats/chat-box.tsx | 26 +-- .../src/core/skill-display/display-adapter.ts | 86 +++------ .../src/core/skill-display/runtime.ts | 171 ++++++++++++++++-- .../backend/app/gateway/routers/skills.py | 124 ++++++++++++- 4 files changed, 303 insertions(+), 104 deletions(-) diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 23ae1bb8..9b41ee95 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -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 }; diff --git a/frontend-web/src/core/skill-display/display-adapter.ts b/frontend-web/src/core/skill-display/display-adapter.ts index ce63e451..2622bcee 100644 --- a/frontend-web/src/core/skill-display/display-adapter.ts +++ b/frontend-web/src/core/skill-display/display-adapter.ts @@ -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 = { titleField: ["title", "m_title", "name", "subject", "headline"], summaryField: ["summary", "snippet", "content_preview", "description", "abstract"], @@ -99,18 +43,23 @@ const candidateFields: Record = { 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((acc, key) => { - if (acc && typeof acc === "object") return (acc as Record)[key]; + if (acc && typeof acc === "object") { + return (acc as Record)[key]; + } return undefined; }, value); } -function findFirstArray(value: unknown, path = ""): { path: string; items: Record[] } | null { +function findFirstArray( + value: unknown, + path = "", +): { path: string; items: Record[] } | null { if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) { return { path, items: value as Record[] }; } @@ -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 { - 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, 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, })); diff --git a/frontend-web/src/core/skill-display/runtime.ts b/frontend-web/src/core/skill-display/runtime.ts index e91c2164..12447a56 100644 --- a/frontend-web/src/core/skill-display/runtime.ts +++ b/frontend-web/src/core/skill-display/runtime.ts @@ -17,6 +17,12 @@ export type RuntimeDisplaySource = DisplaySourceItem & { export type SkillDisplayMap = Map; +type RuntimeToolCall = { + id?: string; + name: string; + args?: Record; +}; + export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap { const map = new Map(); 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 | 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 | 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 }) + .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 | 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 { 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 }).additional_kwargs + ?.tool_call_id, + (message as { additional_kwargs?: Record }).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 }).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); diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 9d8cbcb3..8fc484a2 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -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)