技能展示适配:支持多结果集(result sets)渲染
- 后端 extensions_config 新增 SkillDisplayResultSetConfig,SkillDisplayConfig 增加 resultSets 字段 - threads 引用响应增加 resultSetKey / resultSetLabel,runtime/references 适配多结果集 - 前端 skill-display 适配器/运行时/类型支持多结果集渲染,聊天框、消息组、技能展示面板相应更新 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
00e74a5e45
commit
c860ee85f7
10 files changed
+517
-78
No files matched your search
@@ -228,6 +228,8 @@ class ReferenceSourceResponse(BaseModel):
|
||||
id: str | None = None
|
||||
skillName: str = "backend-reference"
|
||||
mode: str = "citation"
|
||||
resultSetKey: str | None = None
|
||||
resultSetLabel: str | None = None
|
||||
title: str
|
||||
type: str = "result"
|
||||
snippet: str = ""
|
||||
|
||||
+51
@@ -62,6 +62,52 @@ class SkillDisplayTableColumn(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillDisplayResultSetConfig(BaseModel):
|
||||
"""Display adapter for one result array inside a skill response."""
|
||||
|
||||
key: str = Field(description="Stable key for this result set")
|
||||
label: str | None = Field(default=None, description="Human-readable result set label")
|
||||
enabled: bool = Field(default=True, description="Whether this result set is enabled")
|
||||
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
|
||||
default="citation",
|
||||
description="How the frontend should render this result set",
|
||||
)
|
||||
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
|
||||
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
|
||||
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
|
||||
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
|
||||
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
|
||||
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
|
||||
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
|
||||
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
|
||||
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
|
||||
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
|
||||
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
|
||||
link_strategy: Literal["auto", "url", "id"] = Field(
|
||||
default="auto",
|
||||
description="Whether links use a URL field, an ID template, or automatic fallback",
|
||||
alias="linkStrategy",
|
||||
)
|
||||
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
|
||||
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
|
||||
default="singleBracket",
|
||||
description="Citation token style expected in model output",
|
||||
alias="citationToken",
|
||||
)
|
||||
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
|
||||
custom_instruction: str = Field(
|
||||
default="",
|
||||
description="User-authored instruction for custom rendering schema generation",
|
||||
alias="customInstruction",
|
||||
)
|
||||
table_columns: list[SkillDisplayTableColumn] = Field(
|
||||
default_factory=list,
|
||||
description="Columns used when mode is table",
|
||||
alias="tableColumns",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillDisplayConfig(BaseModel):
|
||||
"""Frontend display adapter for structured skill results."""
|
||||
|
||||
@@ -103,6 +149,11 @@ class SkillDisplayConfig(BaseModel):
|
||||
description="Columns used when mode is table",
|
||||
alias="tableColumns",
|
||||
)
|
||||
result_sets: list[SkillDisplayResultSetConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Optional per-result-array display adapters",
|
||||
alias="resultSets",
|
||||
)
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
|
||||
from deerflow.config.extensions_config import (
|
||||
SkillDisplayConfig,
|
||||
SkillDisplayResultSetConfig,
|
||||
get_extensions_config,
|
||||
)
|
||||
|
||||
|
||||
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
|
||||
@@ -222,7 +226,11 @@ def adapt_reference_batches_with_displays(
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not config.enabled or config.mode == "none":
|
||||
has_enabled_result_set = any(
|
||||
result_set.enabled and result_set.mode != "none"
|
||||
for result_set in config.result_sets
|
||||
)
|
||||
if not config.enabled or (config.mode == "none" and not has_enabled_result_set):
|
||||
continue
|
||||
normalized_displays[skill_name] = config
|
||||
|
||||
@@ -257,13 +265,30 @@ def adapt_reference_batches_with_displays(
|
||||
raw = source.get("raw")
|
||||
if display is not None and isinstance(raw, dict):
|
||||
index_value = int(source.get("index") or len(sources) + 1)
|
||||
display_for_source: SkillDisplayConfig | SkillDisplayResultSetConfig = display
|
||||
result_set_key = source.get("resultSetKey")
|
||||
if result_set_key:
|
||||
display_for_source = next(
|
||||
(
|
||||
result_set
|
||||
for result_set in display.result_sets
|
||||
if result_set.key == result_set_key
|
||||
),
|
||||
display,
|
||||
)
|
||||
elif display.result_sets and display.mode == "none":
|
||||
sources.append(source)
|
||||
continue
|
||||
configured = _normalize_with_display_config(
|
||||
raw,
|
||||
index_value,
|
||||
skill_name,
|
||||
display,
|
||||
display_for_source,
|
||||
)
|
||||
if configured is not None:
|
||||
if isinstance(display_for_source, SkillDisplayResultSetConfig):
|
||||
configured["resultSetKey"] = display_for_source.key
|
||||
configured["resultSetLabel"] = display_for_source.label
|
||||
sources.append(
|
||||
{
|
||||
**source,
|
||||
@@ -318,6 +343,8 @@ def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
|
||||
"id": source.get("id"),
|
||||
"skillName": source.get("skillName"),
|
||||
"mode": source.get("mode"),
|
||||
"resultSetKey": source.get("resultSetKey"),
|
||||
"resultSetLabel": source.get("resultSetLabel"),
|
||||
"title": source.get("title"),
|
||||
"source": source.get("source"),
|
||||
"author": source.get("author"),
|
||||
@@ -359,6 +386,16 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
tool_contexts_by_id,
|
||||
)
|
||||
payload = _extract_tool_payload(message)
|
||||
configured_sources = _collect_configured_sources(
|
||||
payload,
|
||||
len(sources) + 1,
|
||||
tool_context,
|
||||
)
|
||||
if configured_sources is not None:
|
||||
for normalized in configured_sources:
|
||||
normalized["index"] = len(sources) + 1
|
||||
sources.append(normalized)
|
||||
continue
|
||||
for raw_item in _first_list(payload):
|
||||
normalized = _normalize_reference_item(
|
||||
raw_item,
|
||||
@@ -373,6 +410,44 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
|
||||
return sources
|
||||
|
||||
|
||||
def _collect_configured_sources(
|
||||
payload: Any,
|
||||
fallback_index: int,
|
||||
tool_context: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
display_match = _resolve_display_match({}, payload, tool_context)
|
||||
if display_match is None:
|
||||
return None
|
||||
skill_name, display = display_match
|
||||
configs: list[SkillDisplayConfig | SkillDisplayResultSetConfig] = [
|
||||
result_set
|
||||
for result_set in display.result_sets
|
||||
if result_set.enabled and result_set.mode != "none"
|
||||
]
|
||||
if not configs:
|
||||
configs = [display]
|
||||
|
||||
sources: list[dict[str, Any]] = []
|
||||
for config in configs:
|
||||
raw_items = _list_at_path(payload, config.result_path)
|
||||
if raw_items is None:
|
||||
continue
|
||||
for raw_item in raw_items:
|
||||
normalized = _normalize_with_display_config(
|
||||
raw_item,
|
||||
fallback_index + len(sources),
|
||||
skill_name,
|
||||
config,
|
||||
)
|
||||
if normalized is None:
|
||||
continue
|
||||
if isinstance(config, SkillDisplayResultSetConfig):
|
||||
normalized["resultSetKey"] = config.key
|
||||
normalized["resultSetLabel"] = config.label
|
||||
sources.append(normalized)
|
||||
return sources
|
||||
|
||||
|
||||
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
|
||||
answer = _find_last_assistant_answer(messages, start, end)
|
||||
if answer is not None:
|
||||
@@ -622,6 +697,19 @@ def _first_list(payload: Any) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def _list_at_path(payload: Any, path: str | None) -> list[dict[str, Any]] | None:
|
||||
cursor = payload
|
||||
if path:
|
||||
for part in path.split("."):
|
||||
if isinstance(cursor, dict):
|
||||
cursor = cursor.get(part)
|
||||
else:
|
||||
return None
|
||||
if not isinstance(cursor, list):
|
||||
return None
|
||||
return [item for item in cursor if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return []
|
||||
@@ -654,7 +742,13 @@ def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
|
||||
if state.display is not None
|
||||
and state.enabled
|
||||
and state.display.enabled
|
||||
and state.display.mode != "none"
|
||||
and (
|
||||
state.display.mode != "none"
|
||||
or any(
|
||||
result_set.enabled and result_set.mode != "none"
|
||||
for result_set in state.display.result_sets
|
||||
)
|
||||
)
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -705,7 +799,7 @@ def _normalize_with_display_config(
|
||||
raw: dict[str, Any],
|
||||
fallback_index: int,
|
||||
skill_name: str,
|
||||
display: SkillDisplayConfig,
|
||||
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
|
||||
) -> dict[str, Any] | None:
|
||||
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
|
||||
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
|
||||
@@ -737,7 +831,10 @@ def _normalize_with_display_config(
|
||||
}
|
||||
|
||||
|
||||
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
|
||||
def _resolve_display_link(
|
||||
raw: dict[str, Any],
|
||||
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
|
||||
) -> str | None:
|
||||
url = _stringify(_get_by_path(raw, display.url_field), "")
|
||||
item_id = _stringify(_get_by_path(raw, display.id_field), "")
|
||||
if display.link_strategy == "url":
|
||||
|
||||
Reference in new issue
Block a user