新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染
- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
This commit is contained in:
1 parent
96b0bae6ca
commit
97f24f17eb
22 files changed
+2313
-330
No files matched your search
@@ -182,10 +182,13 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request
|
||||
current_config = get_extensions_config()
|
||||
|
||||
# Convert request to dict format for JSON serialization
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||
}
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
|
||||
"skills": {
|
||||
name: skill.model_dump(by_alias=True, exclude_none=True)
|
||||
for name, skill in current_config.skills.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Write the configuration to file
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -14,7 +16,7 @@ from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_t
|
||||
from app.gateway.path_utils import resolve_thread_virtual_path
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
|
||||
from deerflow.persistence.skills import SkillStore
|
||||
from deerflow.persistence.tags import TagStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -117,6 +119,7 @@ class SkillResponse(BaseModel):
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
||||
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
|
||||
|
||||
|
||||
@@ -132,6 +135,19 @@ class SkillUpdateRequest(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether to enable or disable the skill")
|
||||
|
||||
|
||||
class SkillDisplayUpdateRequest(BaseModel):
|
||||
"""Request model for updating a skill's frontend display adapter."""
|
||||
|
||||
display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
|
||||
|
||||
|
||||
class SkillDisplaySampleResponse(BaseModel):
|
||||
"""Sample structured output used to infer a display adapter."""
|
||||
|
||||
skill_name: str
|
||||
sample: dict[str, Any]
|
||||
|
||||
|
||||
class SkillInstallRequest(BaseModel):
|
||||
"""Request model for installing a skill from a .skill file."""
|
||||
|
||||
@@ -215,6 +231,14 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
|
||||
display = None
|
||||
try:
|
||||
skill_config = get_extensions_config().skills.get(skill.name)
|
||||
if skill_config and skill_config.display:
|
||||
display = skill_config.display.model_dump(by_alias=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
|
||||
|
||||
return SkillResponse(
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
@@ -232,9 +256,164 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
|
||||
published=published,
|
||||
builtin=builtin,
|
||||
detail=detail,
|
||||
display=display,
|
||||
)
|
||||
|
||||
|
||||
def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
|
||||
return {
|
||||
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()},
|
||||
"skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()},
|
||||
}
|
||||
|
||||
|
||||
_SKILL_SAMPLE_SYSTEM_PROMPT = (
|
||||
"You infer realistic JSON return shapes for AI agent skills. "
|
||||
"Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
|
||||
"represents a plausible successful response from this skill's primary action. "
|
||||
"Rules:\n"
|
||||
"- Output JSON ONLY. No prose. No markdown fences. No comments.\n"
|
||||
"- The top level must be a JSON object (not an array).\n"
|
||||
"- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n"
|
||||
"- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n"
|
||||
"- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n"
|
||||
"- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n"
|
||||
"- Do not invent fields that contradict the skill description."
|
||||
)
|
||||
|
||||
|
||||
def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None:
|
||||
"""Best-effort extraction of a JSON object from an LLM response."""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(match.group(0))
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
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.
|
||||
|
||||
Reads SKILL.md, asks the configured curator model (or main model) to infer
|
||||
a plausible JSON response. Falls back to :func:`_mock_skill_display_sample`
|
||||
when the model is unconfigured, the call times out, or the output cannot
|
||||
be parsed as a JSON object.
|
||||
"""
|
||||
try:
|
||||
skill_content = skill.skill_file.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc)
|
||||
skill_content = ""
|
||||
# Cap the SKILL.md body so very large workflow docs don't blow the context.
|
||||
max_chars = 6000
|
||||
if len(skill_content) > max_chars:
|
||||
skill_content = skill_content[:max_chars] + "\n…(truncated)"
|
||||
|
||||
user_prompt = (
|
||||
f"Skill name: {skill.name}\n"
|
||||
f"Short description: {skill.description}\n\n"
|
||||
f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n"
|
||||
"Return one JSON object that matches what this skill would actually produce on success."
|
||||
)
|
||||
|
||||
try:
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
# Reuse the curator/security-scan model setting; falls back to the main model when unset.
|
||||
model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
|
||||
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
|
||||
response = await asyncio.wait_for(
|
||||
model.ainvoke(
|
||||
[
|
||||
{"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
config={"run_name": "skill_display_sample"},
|
||||
),
|
||||
timeout=45,
|
||||
)
|
||||
parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or ""))
|
||||
if parsed:
|
||||
return parsed
|
||||
logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name)
|
||||
except asyncio.TimeoutError:
|
||||
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)
|
||||
|
||||
|
||||
def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
|
||||
if skill_name == "knowledge-search-v2":
|
||||
return {
|
||||
"success": True,
|
||||
"query": "latest news",
|
||||
"count": 3,
|
||||
"results": [
|
||||
{
|
||||
"index": 1,
|
||||
"recUuid": "rec-001",
|
||||
"relevance_score": 0.982,
|
||||
"title": "Knowledge result one",
|
||||
"source_category": "knowledge/news",
|
||||
"source": "internal news library",
|
||||
"publish_time": "2026-05-25",
|
||||
"content_preview": "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
||||
"content_length": 1200,
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"recUuid": "rec-002",
|
||||
"relevance_score": 0.951,
|
||||
"title": "Knowledge result two",
|
||||
"source_category": "knowledge/news",
|
||||
"source": "internal news library",
|
||||
"publish_time": "2026-05-24",
|
||||
"content_preview": "Mock result used to verify citation ordering and detail preview.",
|
||||
"content_length": 980,
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"recUuid": "rec-003",
|
||||
"relevance_score": 0.921,
|
||||
"title": "Knowledge result three",
|
||||
"source_category": "knowledge/commentary",
|
||||
"source": "internal knowledge library",
|
||||
"publish_time": "2026-05-23",
|
||||
"content_preview": "Mock result used to verify list mode and custom display instruction previews.",
|
||||
"content_length": 760,
|
||||
},
|
||||
],
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"query": "sample query",
|
||||
"results": [
|
||||
{
|
||||
"id": "sample-001",
|
||||
"title": "Sample result",
|
||||
"summary": "Sample structured output used to infer field mapping.",
|
||||
"source": "sample",
|
||||
"updatedAt": "2026-05-25",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
|
||||
"""Index visible-to-user skill records by name. Internal call cache for list endpoints."""
|
||||
records = await skill_store.list_visible(user_id)
|
||||
@@ -1423,12 +1602,13 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
|
||||
current_skill_config = extensions_config.skills.get(skill_name)
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||
enabled=request.enabled,
|
||||
display=current_skill_config.display if current_skill_config else None,
|
||||
)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
|
||||
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
|
||||
}
|
||||
config_data = _dump_extensions_config(extensions_config)
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2)
|
||||
@@ -1453,6 +1633,79 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/skills/{skill_name}/display",
|
||||
response_model=SkillResponse,
|
||||
summary="Update Skill Display Adapter",
|
||||
description="Update a skill's frontend display adapter in extensions_config.json.",
|
||||
)
|
||||
async def update_skill_display(
|
||||
skill_name: str,
|
||||
request: SkillDisplayUpdateRequest,
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
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")
|
||||
|
||||
config_path = ExtensionsConfig.resolve_config_path()
|
||||
if config_path is None:
|
||||
config_path = Path.cwd().parent / "extensions_config.json"
|
||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
current_skill_config = extensions_config.skills.get(skill_name)
|
||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
|
||||
display=request.display,
|
||||
)
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
|
||||
|
||||
reload_extensions_config()
|
||||
await refresh_skills_system_prompt_cache_async()
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
||||
if updated_skill is None:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
|
||||
return _skill_to_response(updated_skill)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/skills/{skill_name}/display-sample",
|
||||
response_model=SkillDisplaySampleResponse,
|
||||
summary="Get Skill Display Sample",
|
||||
description="Return a structured sample response used by the frontend to infer display mapping.",
|
||||
)
|
||||
async def get_skill_display_sample(
|
||||
skill_name: str,
|
||||
config: AppConfig = Depends(get_config),
|
||||
) -> SkillDisplaySampleResponse:
|
||||
try:
|
||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
||||
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")
|
||||
sample = await _generate_skill_display_sample(skill, config)
|
||||
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get display sample: {str(e)}")
|
||||
|
||||
|
||||
class SkillDetailRequest(BaseModel):
|
||||
"""Request body for setting a skill's human-authored detail text."""
|
||||
|
||||
|
||||
@@ -152,8 +152,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"model_name",
|
||||
"mode",
|
||||
"thinking_enabled",
|
||||
"reasoning_effort",
|
||||
"writing_mode",
|
||||
"reasoning_effort",
|
||||
"rag_mode_enabled",
|
||||
"writing_mode",
|
||||
"writing_artifact_path",
|
||||
"canvas_artifact_title",
|
||||
"canvas_artifact_type",
|
||||
|
||||
@@ -840,10 +840,13 @@ class DeerFlowClient:
|
||||
|
||||
current_config = get_extensions_config()
|
||||
|
||||
config_data = {
|
||||
"mcpServers": mcp_servers,
|
||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
||||
}
|
||||
config_data = {
|
||||
"mcpServers": mcp_servers,
|
||||
"skills": {
|
||||
name: skill.model_dump(by_alias=True, exclude_none=True)
|
||||
for name, skill in current_config.skills.items()
|
||||
},
|
||||
}
|
||||
|
||||
self._atomic_write_json(config_path, config_data)
|
||||
|
||||
@@ -903,13 +906,20 @@ class DeerFlowClient:
|
||||
if config_path is None:
|
||||
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
|
||||
|
||||
extensions_config = get_extensions_config()
|
||||
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
|
||||
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
|
||||
}
|
||||
extensions_config = get_extensions_config()
|
||||
current_skill_config = extensions_config.skills.get(name)
|
||||
extensions_config.skills[name] = SkillStateConfig(
|
||||
enabled=enabled,
|
||||
display=current_skill_config.display if current_skill_config else None,
|
||||
)
|
||||
|
||||
config_data = {
|
||||
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
|
||||
"skills": {
|
||||
n: sc.model_dump(by_alias=True, exclude_none=True)
|
||||
for n, sc in extensions_config.skills.items()
|
||||
},
|
||||
}
|
||||
|
||||
self._atomic_write_json(config_path, config_data)
|
||||
|
||||
|
||||
+58
-5
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -53,10 +53,63 @@ class McpServerConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
class SkillDisplayTableColumn(BaseModel):
|
||||
"""A configured table column for structured skill result display."""
|
||||
|
||||
label: str = Field(description="Column label shown in the frontend table")
|
||||
field: str = Field(description="Field name or dot path read from each result item")
|
||||
width: str | None = Field(default=None, description="Optional CSS width for this column")
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
|
||||
class SkillDisplayConfig(BaseModel):
|
||||
"""Frontend display adapter for structured skill results."""
|
||||
|
||||
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
|
||||
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
|
||||
default="none",
|
||||
description="How the frontend should render this skill's structured results",
|
||||
)
|
||||
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")
|
||||
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="doubleBracket",
|
||||
description="Citation token style expected in model output",
|
||||
alias="citationToken",
|
||||
)
|
||||
max_items: int = Field(default=8, 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 SkillStateConfig(BaseModel):
|
||||
"""Configuration for a single skill's state."""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
||||
|
||||
|
||||
class ExtensionsConfig(BaseModel):
|
||||
|
||||
Reference in new issue
Block a user