feat(square): 新增发布广场功能,智能体/技能按广场分类展示
- 后端新增 PublishSquare/PublishSquaresSettings 配置与读写接口 - agents/skills 增加 square_id 字段及对应迁移脚本 - 前端新增发布广场设置页、技能转移选择器,画廊按广场分 Tab 展示 - lead_agent prompt 及若干持久化层适配调整 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3845d5090b
commit
f0aec4cd7f
39 files changed
+1707
-226
No files matched your search
@@ -311,6 +311,12 @@ Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.age
|
||||
|
||||
**Homepage selector (agent picker on the new-chat page)** — single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
||||
|
||||
### Publish Squares (发布广场)
|
||||
|
||||
Admin-configurable, **access-control-free** categories that published agents/skills are filed under (distinct from the password-gated 私密广场). The square list lives in `system_settings.json` → `publish_squares` (`PublishSquaresSettings`: ordered `squares: [{id,name,description}]` + `default_square_id`); helpers `list_publish_squares` / `resolve_default_square_id` / `normalize_square_id` always yield a valid square (built-in "默认广场" fallback when none configured). Admin CRUD: `GET/PUT /api/system-settings/publish-squares` (GET readable by any user — the gallery sub-tabs + publish picker need it; PUT admin-only, auto-slugs missing ids and re-anchors a dangling default).
|
||||
|
||||
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned → resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a 广场-tab square sub-filter in both galleries.
|
||||
|
||||
### Portable Column Types (`deerflow/persistence/types.py`)
|
||||
|
||||
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
|
||||
|
||||
@@ -18,10 +18,10 @@ from app.gateway.auth.password import verify_password_async
|
||||
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.config.system_settings import load_system_settings
|
||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
||||
from deerflow.persistence.agents import AgentStore
|
||||
from deerflow.persistence.tags import TagStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
@@ -42,6 +42,7 @@ class AgentResponse(BaseModel):
|
||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
|
||||
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
||||
square_id: str = Field(default="", description="Publish square (发布广场) the agent is filed under. Empty string = the default square. Independent of is_private.")
|
||||
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
@@ -53,9 +54,9 @@ class AgentResponse(BaseModel):
|
||||
tool_groups: list[str] | None = None
|
||||
skills: list[str] | None = None
|
||||
|
||||
common_questions: list[str] | None = Field(
|
||||
common_questions: list[CommonQuestion] | None = Field(
|
||||
default=None,
|
||||
description="Frequently-asked questions shown in the agent chat sidebar (常用问题).",
|
||||
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
|
||||
)
|
||||
|
||||
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
||||
@@ -103,10 +104,11 @@ class AgentCreateRequest(BaseModel):
|
||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Omit / empty = the default square.")
|
||||
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||
common_questions: list[str] | None = Field(default=None, description="Frequently-asked questions (常用问题) persisted to config.yaml")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
@@ -117,10 +119,11 @@ class AgentUpdateRequest(BaseModel):
|
||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||
published: bool | None = Field(default=None, description="Updated publication state")
|
||||
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Empty string = the default square.")
|
||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||
common_questions: list[str] | None = Field(default=None, description="Updated frequently-asked questions (常用问题, config.yaml)")
|
||||
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
||||
|
||||
|
||||
class AgentSelectorResponse(BaseModel):
|
||||
@@ -224,7 +227,7 @@ def _write_agent_config(
|
||||
skills: list[str] | None = None,
|
||||
model: str | None = None,
|
||||
tool_groups: list[str] | None = None,
|
||||
common_questions: list[str] | None = None,
|
||||
common_questions: list[CommonQuestion] | None = None,
|
||||
) -> None:
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -241,7 +244,16 @@ def _write_agent_config(
|
||||
if tool_groups is not None:
|
||||
config_data["tool_groups"] = tool_groups
|
||||
if common_questions is not None:
|
||||
config_data["common_questions"] = common_questions
|
||||
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
||||
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
||||
# CommonQuestion models or raw dicts/strings.
|
||||
normalized = [
|
||||
q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q)
|
||||
for q in common_questions
|
||||
]
|
||||
config_data["common_questions"] = [
|
||||
q.model_dump() for q in normalized if q.prompt.strip()
|
||||
]
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
@@ -434,6 +446,7 @@ def _record_to_response(
|
||||
user_id=record.get("user_id"),
|
||||
published=bool(record.get("published", False)),
|
||||
is_private=bool(record.get("is_private", False)),
|
||||
square_id=str(record.get("square_id") or ""),
|
||||
builtin=record.get("user_id") is None,
|
||||
created_at=record.get("created_at"),
|
||||
updated_at=record.get("updated_at"),
|
||||
@@ -576,6 +589,10 @@ async def list_agents(
|
||||
default=None,
|
||||
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
|
||||
),
|
||||
square: str | None = Query(
|
||||
default=None,
|
||||
description="Keep only agents filed under this publish square (发布广场). Omit for all squares.",
|
||||
),
|
||||
admin: bool = Query(
|
||||
default=False,
|
||||
description=(
|
||||
@@ -596,6 +613,11 @@ async def list_agents(
|
||||
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||
|
||||
paginate = scope is not None or page is not None
|
||||
square_id = square.strip() if square and square.strip() else None
|
||||
square_is_default = False
|
||||
if square_id:
|
||||
sq = load_system_settings().publish_squares
|
||||
square_is_default = square_id == resolve_default_square_id(sq)
|
||||
if paginate:
|
||||
eff_page = page or 1
|
||||
rows, total = await store.list_paginated(
|
||||
@@ -608,6 +630,8 @@ async def list_agents(
|
||||
admin=admin,
|
||||
sort=sort,
|
||||
owner=owner,
|
||||
square_id=square_id,
|
||||
square_is_default=square_is_default,
|
||||
)
|
||||
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
||||
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
|
||||
@@ -625,6 +649,12 @@ async def list_agents(
|
||||
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
||||
if owner and owner.strip():
|
||||
responses = [r for r in responses if r.user_id == owner.strip()]
|
||||
if square_id:
|
||||
responses = [
|
||||
r
|
||||
for r in responses
|
||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||
]
|
||||
responses = _sort_agent_responses(responses, sort)
|
||||
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
||||
except HTTPException:
|
||||
@@ -754,6 +784,8 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
||||
"user_id": _current_user_id(http_request),
|
||||
"published": request.published,
|
||||
"is_private": request.is_private,
|
||||
# Normalize to a valid configured square id (default when unset).
|
||||
"square_id": normalize_square_id(request.square_id, load_system_settings().publish_squares),
|
||||
}
|
||||
)
|
||||
# Mirror skills/tool_groups/model into the listing cache so the gallery
|
||||
@@ -800,6 +832,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
update_data["published"] = bool(request.published)
|
||||
if "is_private" in fields_set:
|
||||
update_data["is_private"] = bool(request.is_private)
|
||||
if "square_id" in fields_set:
|
||||
# Normalize to a valid configured square id (default when unset /
|
||||
# unknown), so the row never holds a dangling square reference.
|
||||
update_data["square_id"] = normalize_square_id(request.square_id, load_system_settings().publish_squares)
|
||||
# Mutual exclusion: an agent moved into the private square cannot
|
||||
# also be publicly published. Force ``published=false`` whenever the
|
||||
# same request sets ``is_private=true``, even if the client forgot
|
||||
|
||||
@@ -166,6 +166,7 @@ class SkillResponse(BaseModel):
|
||||
owner_user_id: str | None = Field(default=None, description="Custom skill owner (null = built-in or legacy)")
|
||||
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in skills.")
|
||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
||||
square_id: str = Field(default="", description="Publish square (发布广场) the skill is filed under once published. Empty string = the default square.")
|
||||
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")
|
||||
@@ -308,12 +309,14 @@ def _skill_to_response(
|
||||
builtin = skill.category != SkillCategory.CUSTOM
|
||||
detail: str | None = None
|
||||
name_zh: str | None = None
|
||||
square_id = ""
|
||||
if ownership is not None:
|
||||
owner_user_id = ownership.get("owner_user_id")
|
||||
published = bool(ownership.get("published", False))
|
||||
builtin = bool(ownership.get("builtin", owner_user_id is None))
|
||||
detail = ownership.get("detail")
|
||||
name_zh = ownership.get("name_zh")
|
||||
square_id = str(ownership.get("square_id") or "")
|
||||
|
||||
display = None if display_override is _UNSET else display_override
|
||||
if display_override is _UNSET:
|
||||
@@ -342,6 +345,7 @@ def _skill_to_response(
|
||||
source=entry.get("source", "upload"),
|
||||
owner_user_id=owner_user_id,
|
||||
published=published,
|
||||
square_id=square_id,
|
||||
builtin=builtin,
|
||||
detail=detail,
|
||||
display=display,
|
||||
@@ -772,6 +776,7 @@ async def list_skills(
|
||||
tag_store: TagStore = Depends(get_tag_store),
|
||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on skill name or Chinese name (name_zh)"),
|
||||
tag_ids: list[str] | None = Query(default=None, description="Keep skills carrying any of these tags (OR)"),
|
||||
square: str | None = Query(default=None, description="Keep only skills filed under this publish square (发布广场). Omit for all squares."),
|
||||
sort: str = Query(
|
||||
default="time_desc",
|
||||
pattern="^(time_desc|time_asc|activity_desc|activity_asc|usage_desc|usage_asc|name_asc|name_desc)$",
|
||||
@@ -831,6 +836,16 @@ async def list_skills(
|
||||
)
|
||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||
responses = await _attach_owner_usernames(responses)
|
||||
square_id = square.strip() if square and square.strip() else None
|
||||
if square_id:
|
||||
from deerflow.config.system_settings import load_system_settings, resolve_default_square_id
|
||||
|
||||
square_is_default = square_id == resolve_default_square_id(load_system_settings().publish_squares)
|
||||
responses = [
|
||||
r
|
||||
for r in responses
|
||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||
]
|
||||
responses = _sort_skill_responses(responses, sort)
|
||||
return SkillsListResponse(skills=responses)
|
||||
except HTTPException:
|
||||
@@ -1366,12 +1381,14 @@ class SkillPublishRequest(BaseModel):
|
||||
"""Request body for toggling a custom skill's published flag."""
|
||||
|
||||
published: bool = Field(..., description="Whether to publish (True) or unpublish (False)")
|
||||
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the skill under when publishing. Omit / empty = the default square.")
|
||||
|
||||
|
||||
class SkillPublishResponse(BaseModel):
|
||||
name: str
|
||||
owner_user_id: str | None
|
||||
published: bool
|
||||
square_id: str = ""
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -1393,10 +1410,18 @@ async def update_custom_skill_published(
|
||||
record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||
|
||||
was_published = bool(record.get("published", False))
|
||||
# When publishing, also file the skill into the chosen square (normalized
|
||||
# to a valid configured square id; default when unset). Only one square
|
||||
# per skill — a single column, so it can never be "published twice".
|
||||
update_fields: dict[str, Any] = {"published": body.published}
|
||||
if body.published:
|
||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id
|
||||
|
||||
update_fields["square_id"] = normalize_square_id(body.square_id, load_system_settings().publish_squares)
|
||||
if mode == "admin":
|
||||
updated = await skill_store.update_admin(skill_name, {"published": body.published})
|
||||
updated = await skill_store.update_admin(skill_name, update_fields)
|
||||
else:
|
||||
updated = await skill_store.update(skill_name, user_id, {"published": body.published})
|
||||
updated = await skill_store.update(skill_name, user_id, update_fields)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to persist published state")
|
||||
|
||||
@@ -1419,6 +1444,7 @@ async def update_custom_skill_published(
|
||||
name=updated["name"],
|
||||
owner_user_id=updated.get("owner_user_id"),
|
||||
published=bool(updated.get("published", False)),
|
||||
square_id=str(updated.get("square_id") or ""),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -14,8 +14,12 @@ from deerflow.config import get_app_config
|
||||
from deerflow.config.system_settings import (
|
||||
LeaderboardSettings,
|
||||
PromptPrefixSettings,
|
||||
PublishSquare,
|
||||
PublishSquaresSettings,
|
||||
SystemSettings,
|
||||
list_publish_squares,
|
||||
load_system_settings,
|
||||
resolve_default_square_id,
|
||||
save_system_settings,
|
||||
)
|
||||
|
||||
@@ -219,6 +223,88 @@ async def update_private_square(body: PrivateSquareUpdateRequest, request: Reque
|
||||
)
|
||||
|
||||
|
||||
class PublishSquareItem(BaseModel):
|
||||
id: str = Field(..., description="Stable square id")
|
||||
name: str = Field(..., description="Display name / tab label")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
|
||||
|
||||
class PublishSquaresResponse(BaseModel):
|
||||
"""The configured publish squares plus the resolved default.
|
||||
|
||||
Readable by any logged-in user — squares are pure category tabs with no
|
||||
access control, so the gallery needs the list to render its sub-tabs and
|
||||
the publish flow needs it to populate the square picker.
|
||||
"""
|
||||
|
||||
squares: list[PublishSquareItem] = Field(default_factory=list)
|
||||
default_square_id: str = Field(..., description="Square id used when a publisher picks none")
|
||||
|
||||
|
||||
class PublishSquaresUpdateRequest(BaseModel):
|
||||
squares: list[PublishSquareItem] | None = Field(default=None, description="Full ordered replacement list of squares")
|
||||
default_square_id: str | None = Field(default=None, description="Which square id is the publish default")
|
||||
|
||||
|
||||
def _publish_squares_response(settings: PublishSquaresSettings) -> PublishSquaresResponse:
|
||||
squares = list_publish_squares(settings)
|
||||
return PublishSquaresResponse(
|
||||
squares=[PublishSquareItem(id=s.id, name=s.name, description=s.description) for s in squares],
|
||||
default_square_id=resolve_default_square_id(settings),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/publish-squares", response_model=PublishSquaresResponse)
|
||||
async def get_publish_squares() -> PublishSquaresResponse:
|
||||
"""Return the configured publish squares (readable by any logged-in user)."""
|
||||
return _publish_squares_response(load_system_settings().publish_squares)
|
||||
|
||||
|
||||
@router.put("/publish-squares", response_model=PublishSquaresResponse)
|
||||
async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Request) -> PublishSquaresResponse:
|
||||
"""Replace the publish-squares configuration. Admin only.
|
||||
|
||||
``squares`` is a full ordered replacement. Each square needs a non-empty
|
||||
name; a missing id is auto-generated from a slug of the name. Duplicate ids
|
||||
are de-duplicated (first wins). ``default_square_id`` must reference one of
|
||||
the surviving squares, else it falls back to the first.
|
||||
"""
|
||||
from uuid import uuid4
|
||||
|
||||
await _require_admin(request)
|
||||
|
||||
current = load_system_settings()
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
update: dict[str, object] = {}
|
||||
|
||||
if "squares" in fields and fields["squares"] is not None:
|
||||
normalized: list[PublishSquare] = []
|
||||
seen_ids: set[str] = set()
|
||||
for raw in body.squares or []:
|
||||
name = (raw.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
sid = (raw.id or "").strip() or uuid4().hex[:8]
|
||||
if sid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(sid)
|
||||
normalized.append(PublishSquare(id=sid, name=name, description=(raw.description or "").strip()))
|
||||
update["squares"] = normalized
|
||||
|
||||
if "default_square_id" in fields and fields["default_square_id"] is not None:
|
||||
update["default_square_id"] = str(fields["default_square_id"]).strip()
|
||||
|
||||
if update:
|
||||
updated = current.publish_squares.model_copy(update=update)
|
||||
# Re-anchor the default to a surviving square id.
|
||||
if updated.squares and updated.default_square_id not in {s.id for s in updated.squares}:
|
||||
updated = updated.model_copy(update={"default_square_id": updated.squares[0].id})
|
||||
saved = save_system_settings(current.model_copy(update={"publish_squares": updated}))
|
||||
else:
|
||||
saved = current
|
||||
return _publish_squares_response(saved.publish_squares)
|
||||
|
||||
|
||||
class AppearanceResponse(BaseModel):
|
||||
"""Effective system-wide appearance defaults."""
|
||||
|
||||
|
||||
+32
-7
@@ -20,7 +20,7 @@ from deerflow.agents.middlewares.token_usage_middleware import TokenUsageMiddlew
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares
|
||||
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||
from deerflow.config.agents_config import load_agent_config, load_agent_soul, validate_agent_id
|
||||
from deerflow.config.app_config import AppConfig, get_app_config
|
||||
from deerflow.models import create_chat_model
|
||||
|
||||
@@ -535,11 +535,30 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
|
||||
default_tools = [notebook_search_tool] + default_tools
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=apply_prompt_template(
|
||||
# Custom agents are steered ONLY by their own SOUL.md — no generic
|
||||
# super-agent system template and no personal memory injection. We branch to
|
||||
# the agent-only prompt builder whenever a custom agent config carries a
|
||||
# non-empty SOUL.md. The plain default lead agent (no agent_config) and
|
||||
# notebook mode keep the full template. See apply_agent_only_prompt_template.
|
||||
has_agent_soul = bool(agent_config) and bool(load_agent_soul(agent_id))
|
||||
use_agent_only_prompt = has_agent_soul and not is_notebook_mode
|
||||
|
||||
if use_agent_only_prompt:
|
||||
from deerflow.agents.lead_agent.prompt import apply_agent_only_prompt_template
|
||||
|
||||
system_prompt = apply_agent_only_prompt_template(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_display_name,
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||
app_config=resolved_app_config,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
writing_mode=writing_mode,
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
)
|
||||
else:
|
||||
system_prompt = apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_id=agent_id,
|
||||
@@ -552,6 +571,12 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
),
|
||||
)
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=system_prompt,
|
||||
state_schema=ThreadState,
|
||||
)
|
||||
+121
@@ -615,6 +615,127 @@ combined with a FastAPI gateway for REST API access [citation:FastAPI](https://f
|
||||
"""
|
||||
|
||||
|
||||
# Minimal system prompt for custom agents that should be steered ONLY by their
|
||||
# own SOUL.md — no generic "super agent" persona scaffolding (role line, thinking
|
||||
# style, clarification system, citation rules, response-style / critical-reminder
|
||||
# defaults) and no personal memory injection. Only the mechanical sections an
|
||||
# agent still needs to actually use its configured skills/tools are kept.
|
||||
AGENT_ONLY_PROMPT_TEMPLATE = """{soul}
|
||||
{network_policy_section}{skills_section}
|
||||
|
||||
{deferred_tools_section}
|
||||
|
||||
{writing_mode_section}
|
||||
|
||||
{subagent_section}
|
||||
|
||||
<working_directory existed="true">
|
||||
- User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context)
|
||||
- User workspace: `/mnt/user-data/workspace` - Working directory for temporary files
|
||||
- Output files: `/mnt/user-data/outputs` - Final deliverables must be saved here
|
||||
|
||||
**File Management:**
|
||||
- Uploaded files are automatically listed in the <uploaded_files> section before each request
|
||||
- Use `read_file` tool to read uploaded files using their paths from the list
|
||||
- For PDF, PPT, Excel, and Word files, converted Markdown versions (*.md) are available alongside originals
|
||||
- Treat `/mnt/user-data/workspace` as your default current working directory for coding and file-editing tasks
|
||||
- Final deliverables must be copied to `/mnt/user-data/outputs` and presented using `present_files` tool
|
||||
{acp_section}
|
||||
</working_directory>
|
||||
"""
|
||||
|
||||
|
||||
def apply_agent_only_prompt_template(
|
||||
*,
|
||||
agent_id: str | None,
|
||||
agent_name: str | None,
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
rag_mode_enabled: bool = False,
|
||||
writing_mode: bool = False,
|
||||
writing_artifact_path: str | None = None,
|
||||
) -> str:
|
||||
"""Build a custom agent's system prompt from ONLY its SOUL.md plus the
|
||||
mechanical sections required for its configured skills/tools to work.
|
||||
|
||||
Unlike :func:`apply_prompt_template`, this deliberately omits:
|
||||
- the generic ``<role>`` "internal super agent" framing,
|
||||
- ``<thinking_style>`` / ``<clarification_system>`` / ``<response_style>`` /
|
||||
``<citations>`` / ``<critical_reminders>`` behavioural defaults,
|
||||
- the personal ``<memory>`` injection (USER.md / MEMORY.md).
|
||||
|
||||
The agent's SOUL.md is therefore the sole source of persona and behaviour.
|
||||
Skills, deferred tools, the subagent orchestration section (when enabled) and
|
||||
the working-directory contract are kept because they are capability wiring,
|
||||
not persona — dropping them would silently break agents that rely on skills
|
||||
or file output.
|
||||
"""
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
soul = get_agent_soul(storage_agent_id)
|
||||
|
||||
skills_section = get_skills_prompt_section(
|
||||
available_skills,
|
||||
app_config=app_config,
|
||||
display_enabled=rag_mode_enabled,
|
||||
)
|
||||
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
|
||||
|
||||
n = max_concurrent_subagents
|
||||
subagent_section = _build_subagent_section(n, app_config=app_config) if subagent_enabled else ""
|
||||
|
||||
current_writing_artifact = (
|
||||
f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n"
|
||||
if writing_artifact_path
|
||||
else ""
|
||||
)
|
||||
writing_mode_section = (
|
||||
f"""<writing_mode>
|
||||
Writing mode is enabled for this run.
|
||||
- Your final deliverable MUST be a Markdown file saved under `/mnt/user-data/outputs/` (e.g. `/mnt/user-data/outputs/report.md`).
|
||||
- If a current Markdown artifact is provided below, revise that file rather than creating a new one unless the user explicitly asks for a separate document.
|
||||
- After writing the file, call `present_files` with the Markdown file path.
|
||||
{current_writing_artifact}</writing_mode>
|
||||
"""
|
||||
if writing_mode
|
||||
else ""
|
||||
)
|
||||
|
||||
acp_section = _build_acp_section(app_config=app_config)
|
||||
custom_mounts_section = _build_custom_mounts_section(app_config=app_config)
|
||||
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
|
||||
|
||||
network_policy_section = _build_network_policy_section()
|
||||
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering per search term, tool, or skill.
|
||||
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
|
||||
- Do not output `<skill-display .../>` in RAG/reference mode; the frontend renders the right reference panel.
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
prompt = AGENT_ONLY_PROMPT_TEMPLATE.format(
|
||||
soul=soul,
|
||||
skills_section=skills_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
writing_mode_section=writing_mode_section,
|
||||
subagent_section=subagent_section,
|
||||
network_policy_section=network_policy_section,
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
|
||||
return prompt
|
||||
|
||||
|
||||
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
|
||||
"""组装注入系统提示词的记忆上下文 (Memory V2)。
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
@@ -56,6 +56,32 @@ def validate_agent_name(name: str | None) -> str | None:
|
||||
return validate_agent_id(name)
|
||||
|
||||
|
||||
class CommonQuestion(BaseModel):
|
||||
"""A recommended question shown on the agent chat home / sidebar.
|
||||
|
||||
``title`` is the short label rendered on the question chip; ``prompt`` is the
|
||||
full message actually sent when the user clicks it. Splitting the two lets an
|
||||
admin keep the chip terse while still sending a richly-worded question.
|
||||
|
||||
Legacy configs stored questions as bare strings (content only). The
|
||||
before-validator coerces such a string into ``{title, prompt}`` so old
|
||||
config.yaml files keep loading without a migration — the title falls back to
|
||||
a truncated copy of the content.
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
prompt: str = ""
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _coerce_legacy_string(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
title = text if len(text) <= 40 else text[:40].rstrip() + "…"
|
||||
return {"title": title, "prompt": text}
|
||||
return value
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Configuration for a custom agent."""
|
||||
|
||||
@@ -71,7 +97,9 @@ class AgentConfig(BaseModel):
|
||||
skills: list[str] | None = None
|
||||
# Frequently-asked questions ("常用问题") shown in the agent chat sidebar.
|
||||
# Display/UX metadata only — the runtime never reads these. None = none set.
|
||||
common_questions: list[str] | None = None
|
||||
# Accepts both the legacy ``list[str]`` shape and the new ``{title, prompt}``
|
||||
# objects (see CommonQuestion).
|
||||
common_questions: list[CommonQuestion] | None = None
|
||||
|
||||
|
||||
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
|
||||
|
||||
@@ -86,6 +86,75 @@ class PrivateSquareSettings(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
#: Id of the always-present built-in default square (used when the admin has
|
||||
#: configured no squares, or when a published item picks none).
|
||||
DEFAULT_SQUARE_ID = "default"
|
||||
|
||||
|
||||
class PublishSquare(BaseModel):
|
||||
"""One admin-configured publish square (发布广场).
|
||||
|
||||
A pure organizational category that published agents/skills can be filed
|
||||
under — unlike the password-gated :class:`PrivateSquareSettings`, a square
|
||||
has **no access control**: every logged-in user can browse every square.
|
||||
Each agent/skill belongs to at most one square (a single ``square_id``
|
||||
field on the row), so an item can never be "published twice".
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="Stable square id (slug/uuid); used as agents.square_id / skills.square_id")
|
||||
name: str = Field(..., description="Display name shown as the square's tab label")
|
||||
description: str = Field(default="", description="Optional description shown in the admin config / tab hint")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class PublishSquaresSettings(BaseModel):
|
||||
"""Admin-configurable list of publish squares.
|
||||
|
||||
``squares`` is display-ordered. ``default_square_id`` selects the square a
|
||||
publisher lands in when they do not pick one explicitly. There is always at
|
||||
least one effective square — :func:`list_publish_squares` injects a built-in
|
||||
"默认广场" when the admin has configured none, so the publish flow always has
|
||||
a valid target.
|
||||
"""
|
||||
|
||||
squares: list[PublishSquare] = Field(default_factory=list, description="Ordered list of configured squares")
|
||||
default_square_id: str = Field(default=DEFAULT_SQUARE_ID, description="Square id used when a publisher picks none")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
def list_publish_squares(settings: PublishSquaresSettings) -> list[PublishSquare]:
|
||||
"""Return the effective square list, always non-empty.
|
||||
|
||||
Falls back to a single built-in default square when the admin has not
|
||||
configured any, so callers never have to special-case the empty state.
|
||||
"""
|
||||
if settings.squares:
|
||||
return list(settings.squares)
|
||||
return [PublishSquare(id=DEFAULT_SQUARE_ID, name="默认广场", description="")]
|
||||
|
||||
|
||||
def resolve_default_square_id(settings: PublishSquaresSettings) -> str:
|
||||
"""Resolve the effective default square id against the configured set."""
|
||||
ids = [s.id for s in settings.squares]
|
||||
if settings.default_square_id and settings.default_square_id in ids:
|
||||
return settings.default_square_id
|
||||
return ids[0] if ids else DEFAULT_SQUARE_ID
|
||||
|
||||
|
||||
def normalize_square_id(square_id: str | None, settings: PublishSquaresSettings) -> str:
|
||||
"""Coerce an arbitrary ``square_id`` to a valid configured square id.
|
||||
|
||||
Empty / unknown / orphaned (square later deleted) ids all collapse to the
|
||||
effective default square, so a published item always resolves to a real
|
||||
tab.
|
||||
"""
|
||||
default_id = resolve_default_square_id(settings)
|
||||
if not square_id:
|
||||
return default_id
|
||||
valid = {s.id for s in list_publish_squares(settings)}
|
||||
return square_id if square_id in valid else default_id
|
||||
|
||||
|
||||
class LeaderboardSettings(BaseModel):
|
||||
"""Admin-configurable defaults for the user leaderboard analytics."""
|
||||
|
||||
@@ -102,6 +171,7 @@ class SystemSettings(BaseModel):
|
||||
|
||||
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
|
||||
private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings)
|
||||
publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings)
|
||||
appearance: AppearanceSettings = Field(default_factory=AppearanceSettings)
|
||||
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
|
||||
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
|
||||
|
||||
@@ -229,8 +229,8 @@ const GRAPH = __GRAPH_DATA__;
|
||||
// Theme is supplied by the embedding iframe (it can't read the parent's CSS).
|
||||
const THEME = (new URLSearchParams(location.search).get('theme') === 'light') ? 'light' : 'dark';
|
||||
const PAL = {
|
||||
dark: {note:'#5b8cff', entity:'#ffb454', label:'rgba(231,236,255,.92)', edge:[140,165,255], halo:'rgba(11,16,32,.55)'},
|
||||
light: {note:'#2f6df6', entity:'#d97706', label:'rgba(31,36,51,.92)', edge:[70,110,200], halo:'rgba(255,255,255,.65)'},
|
||||
dark: {note:'#5b8cff', entity:'#ffb454', label:'rgba(231,236,255,.92)', edge:[150,178,255], halo:'rgba(11,16,32,.55)'},
|
||||
light: {note:'#2f6df6', entity:'#d97706', label:'rgba(31,36,51,.92)', edge:[63,96,168], halo:'rgba(255,255,255,.7)'},
|
||||
}[THEME];
|
||||
document.documentElement.classList.toggle('light', THEME === 'light');
|
||||
document.getElementById('lg-note').style.background = PAL.note;
|
||||
@@ -317,12 +317,16 @@ function draw(){
|
||||
const act=active(), nb=neighbors(act), zoomed=cam.k>1.35;
|
||||
const [er,eg,eb]=PAL.edge;
|
||||
|
||||
// edges: dim by default; when a node is focused, only its edges stay bright.
|
||||
// edges: clearly visible by default; when a node is focused, only its edges
|
||||
// stay bright and the rest fade back (but never to invisible).
|
||||
for(const e of edges){
|
||||
const lit = act && (e.s===act||e.t===act);
|
||||
const op = act ? (lit?0.9:0.05) : Math.min(0.5,0.12+e.w*0.05);
|
||||
const op = act ? (lit?0.98:0.12) : Math.min(0.85,0.55+e.w*0.06);
|
||||
// Keep a stable on-screen thickness regardless of zoom (>=1px), so lines
|
||||
// never thin out to a hairline when zoomed in or on dense graphs.
|
||||
const screenW = lit ? 2.6 : Math.min(2.4,1.4+e.w*0.45);
|
||||
ctx.strokeStyle='rgba('+er+','+eg+','+eb+','+op+')';
|
||||
ctx.lineWidth=(lit?1.6:0.8)*Math.max(0.6,e.w*0.55)/Math.sqrt(cam.k);
|
||||
ctx.lineWidth=Math.max(1,screenW)/cam.k;
|
||||
ctx.beginPath();ctx.moveTo(e.s.x,e.s.y);ctx.lineTo(e.t.x,e.t.y);ctx.stroke();
|
||||
}
|
||||
// nodes
|
||||
|
||||
@@ -131,6 +131,8 @@ class AgentStore(ABC):
|
||||
admin: bool = False,
|
||||
sort: str = "time_desc",
|
||||
owner: str | None = None,
|
||||
square_id: str | None = None,
|
||||
square_is_default: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return one page of agents plus the total matching count.
|
||||
|
||||
|
||||
+16
-3
@@ -61,6 +61,8 @@ class MemoryAgentStore(AgentStore):
|
||||
"description": data.get("description") or "",
|
||||
"user_id": data.get("user_id"),
|
||||
"published": bool(data.get("published", False)),
|
||||
"is_private": bool(data.get("is_private", False)),
|
||||
"square_id": str(data.get("square_id") or ""),
|
||||
"featured_order": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
@@ -93,7 +95,7 @@ class MemoryAgentStore(AgentStore):
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None or row.get("user_id") != user_id:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
@@ -103,7 +105,7 @@ class MemoryAgentStore(AgentStore):
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None or row.get("user_id") is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
@@ -159,7 +161,7 @@ class MemoryAgentStore(AgentStore):
|
||||
row = self._agents.get(agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
row[key] = data[key]
|
||||
row["updated_at"] = _now()
|
||||
@@ -198,16 +200,27 @@ class MemoryAgentStore(AgentStore):
|
||||
admin: bool = False,
|
||||
sort: str = "time_desc",
|
||||
owner: str | None = None,
|
||||
square_id: str | None = None,
|
||||
square_is_default: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
# NOTE: the in-memory store has no tag-assignment data, so ``tag_ids``
|
||||
# is ignored here. Tag-filter behavior is covered by the SQL repo tests.
|
||||
owner_key = owner.strip() if owner and owner.strip() else None
|
||||
square_key = square_id.strip() if square_id and square_id.strip() else None
|
||||
|
||||
def _square_ok(a: dict[str, Any]) -> bool:
|
||||
if square_key is None:
|
||||
return True
|
||||
sid = str(a.get("square_id") or "")
|
||||
return sid == square_key or (square_is_default and sid == "")
|
||||
|
||||
matched = [
|
||||
a
|
||||
for a in self._agents.values()
|
||||
if self._matches_scope(a, user_id=user_id, scope=scope, admin=admin)
|
||||
and (not search or not search.strip() or name_matches(a.get("name") or "", search))
|
||||
and (owner_key is None or a.get("user_id") == owner_key)
|
||||
and _square_ok(a)
|
||||
]
|
||||
if sort == "time_asc":
|
||||
matched.sort(key=lambda a: (a.get("created_at") or "", a.get("id") or ""))
|
||||
|
||||
+23
-17
@@ -27,6 +27,12 @@ class AgentRow(Base):
|
||||
# visible to its owner in the "我的" tab, and visible to anyone in the
|
||||
# private-square listing once the password has been verified.
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||
# Publish square (发布广场) this agent is filed under. A pure organizational
|
||||
# category with no access control — independent of ``published`` /
|
||||
# ``is_private``. Empty string = "not assigned" → the listing layer treats
|
||||
# it as the admin-configured default square. Each agent belongs to at most
|
||||
# one square (single column), so it can never be published to two squares.
|
||||
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
||||
# NULL = not featured on the homepage selector. Non-NULL = admin-curated
|
||||
# featured rank (lower sorts first). Only ``published=True`` agents are
|
||||
# eligible; the router enforces that invariant on writes. BIGINT so the
|
||||
@@ -37,7 +43,7 @@ class AgentRow(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentFavoriteRow(Base):
|
||||
class AgentFavoriteRow(Base):
|
||||
"""A user's bookmark of a (typically public-square) agent.
|
||||
|
||||
Lets a user collect agents from the 广场 (square) into their "我的" tab
|
||||
@@ -51,22 +57,22 @@ class AgentFavoriteRow(Base):
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentPinRow(Base):
|
||||
"""A per-user pin that keeps an agent near the top of that user's lists."""
|
||||
|
||||
__tablename__ = "agent_pins"
|
||||
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentSkillRow(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentPinRow(Base):
|
||||
"""A per-user pin that keeps an agent near the top of that user's lists."""
|
||||
|
||||
__tablename__ = "agent_pins"
|
||||
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_pins_user_agent"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class AgentSkillRow(Base):
|
||||
"""Denormalized cache of a custom agent's selected skills.
|
||||
|
||||
``config.yaml`` under ``.deer-flow/agents/{id}/`` stays the source of truth
|
||||
|
||||
+14
-3
@@ -93,6 +93,7 @@ class AgentRepository(AgentStore):
|
||||
user_id=data.get("user_id"),
|
||||
published=bool(data.get("published", False)),
|
||||
is_private=bool(data.get("is_private", False)),
|
||||
square_id=str(data.get("square_id") or ""),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -131,7 +132,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -144,7 +145,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None or row.user_id is not None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -224,7 +225,7 @@ class AgentRepository(AgentStore):
|
||||
row = await session.get(AgentRow, agent_id)
|
||||
if row is None:
|
||||
return None
|
||||
for key in ("name", "description", "published", "is_private"):
|
||||
for key in ("name", "description", "published", "is_private", "square_id"):
|
||||
if key in data:
|
||||
setattr(row, key, data[key])
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -290,6 +291,8 @@ class AgentRepository(AgentStore):
|
||||
admin: bool = False,
|
||||
sort: str = "time_desc",
|
||||
owner: str | None = None,
|
||||
square_id: str | None = None,
|
||||
square_is_default: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
filters = []
|
||||
scope_filter = self._scope_filter(user_id=user_id, scope=scope, admin=admin)
|
||||
@@ -302,6 +305,14 @@ class AgentRepository(AgentStore):
|
||||
if owner and owner.strip():
|
||||
# Filter to one publisher. Built-in agents (user_id IS NULL) never match.
|
||||
filters.append(AgentRow.user_id == owner.strip())
|
||||
if square_id and square_id.strip():
|
||||
sid = square_id.strip()
|
||||
if square_is_default:
|
||||
# Legacy / unassigned rows (square_id == "") belong to the
|
||||
# default square, so include them alongside explicit matches.
|
||||
filters.append(or_(AgentRow.square_id == sid, AgentRow.square_id == ""))
|
||||
else:
|
||||
filters.append(AgentRow.square_id == sid)
|
||||
|
||||
count_stmt = select(func.count()).select_from(AgentRow)
|
||||
pin_subq = select(AgentPinRow.agent_id).where(AgentPinRow.user_id == user_id)
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
"""Add ``agents.square_id`` and ``skills.square_id`` for the publish-square (发布广场) feature.
|
||||
|
||||
A nullable-default (server_default '') string column tagging which admin-
|
||||
configured publish square an agent / skill is filed under. Pure organizational
|
||||
category — independent of the ``published`` / ``is_private`` flags. Empty string
|
||||
means "unassigned" and is resolved to the default square at read time.
|
||||
|
||||
Revision ID: 20260611_01
|
||||
Revises: 20260609_02
|
||||
Create Date: 2026-06-11
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260611_01"
|
||||
down_revision = "20260609_02"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _has_index(table_name: str, index_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _add_square_id(table_name: str, index_name: str) -> None:
|
||||
if _has_table(table_name) and not _has_column(table_name, "square_id"):
|
||||
op.add_column(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"square_id",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
if _has_table(table_name) and not _has_index(table_name, index_name):
|
||||
op.create_index(index_name, table_name, ["square_id"])
|
||||
|
||||
|
||||
def _drop_square_id(table_name: str, index_name: str) -> None:
|
||||
if _has_index(table_name, index_name):
|
||||
op.drop_index(index_name, table_name=table_name)
|
||||
if _has_column(table_name, "square_id"):
|
||||
op.drop_column(table_name, "square_id")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_add_square_id("agents", "ix_agents_square_id")
|
||||
_add_square_id("skills", "ix_skills_square_id")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_square_id("skills", "ix_skills_square_id")
|
||||
_drop_square_id("agents", "ix_agents_square_id")
|
||||
+5
@@ -71,6 +71,7 @@ class MemorySkillStore(SkillStore):
|
||||
"published": bool(data.get("published", False)),
|
||||
"detail": data.get("detail"),
|
||||
"name_zh": data.get("name_zh"),
|
||||
"square_id": str(data.get("square_id") or ""),
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
@@ -106,6 +107,8 @@ class MemorySkillStore(SkillStore):
|
||||
record["detail"] = data["detail"]
|
||||
if "name_zh" in data:
|
||||
record["name_zh"] = data["name_zh"]
|
||||
if "square_id" in data:
|
||||
record["square_id"] = str(data["square_id"] or "")
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
@@ -121,6 +124,8 @@ class MemorySkillStore(SkillStore):
|
||||
record["detail"] = data["detail"]
|
||||
if "name_zh" in data:
|
||||
record["name_zh"] = data["name_zh"]
|
||||
if "square_id" in data:
|
||||
record["square_id"] = str(data["square_id"] or "")
|
||||
record["updated_at"] = _now()
|
||||
return _with_derived(_copy(record)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ class SkillRow(Base):
|
||||
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
||||
# shared source file is never rewritten.
|
||||
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
||||
# Publish square (发布广场) this skill is filed under once published. Pure
|
||||
# organizational category, no access control. Empty string = unassigned →
|
||||
# the listing layer resolves it to the default square. One square per skill.
|
||||
square_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class SkillRepository(SkillStore):
|
||||
published=bool(data.get("published", False)),
|
||||
detail=data.get("detail"),
|
||||
name_zh=data.get("name_zh"),
|
||||
square_id=str(data.get("square_id") or ""),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -109,6 +110,8 @@ class SkillRepository(SkillStore):
|
||||
row.detail = data["detail"]
|
||||
if "name_zh" in data:
|
||||
row.name_zh = data["name_zh"]
|
||||
if "square_id" in data:
|
||||
row.square_id = str(data["square_id"] or "")
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
@@ -127,6 +130,8 @@ class SkillRepository(SkillStore):
|
||||
row.detail = data["detail"]
|
||||
if "name_zh" in data:
|
||||
row.name_zh = data["name_zh"]
|
||||
if "square_id" in data:
|
||||
row.square_id = str(data["square_id"] or "")
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
@@ -62,6 +62,14 @@ CALL _add_column_if_missing('skills', 'name_zh', 'VARCHAR(128) NULL DEFAULT NULL
|
||||
-- detail: 技能的长文介绍,对应 migration 20260518_01。
|
||||
-- ORM: SkillRow.detail = Text, nullable=True。
|
||||
CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
|
||||
-- square_id: 技能所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||
-- ORM: SkillRow.square_id = String(64), nullable=False, default=''。
|
||||
CALL _add_column_if_missing('skills', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
|
||||
-- ---- agents ------------------------------------------------------------------
|
||||
-- square_id: 智能体所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||
-- ORM: AgentRow.square_id = String(64), nullable=False, default=''。
|
||||
CALL _add_column_if_missing('agents', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
@@ -70,5 +78,6 @@ DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND (table_name = 'skills' AND column_name IN ('name_zh', 'detail'))
|
||||
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id'))
|
||||
OR (table_name = 'agents' AND column_name IN ('square_id')))
|
||||
ORDER BY table_name, column_name;
|
||||
@@ -187,7 +187,11 @@ def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths
|
||||
|
||||
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
||||
agent_id = "agent-cq-1"
|
||||
questions = ["这个智能体能做什么?", "如何上传文件?"]
|
||||
# Structured {title, prompt} questions round-trip as clean dict mappings.
|
||||
questions = [
|
||||
{"title": "能做什么", "prompt": "这个智能体能做什么?"},
|
||||
{"title": "上传文件", "prompt": "如何上传文件?"},
|
||||
]
|
||||
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
@@ -195,7 +199,21 @@ def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
|
||||
assert raw["common_questions"] == questions
|
||||
|
||||
extras = _load_config_extras(agent_id)
|
||||
assert extras["common_questions"] == questions
|
||||
assert [q.model_dump() for q in extras["common_questions"]] == questions
|
||||
|
||||
|
||||
def test_write_agent_config_coerces_legacy_string_questions(isolated_paths) -> None:
|
||||
"""Legacy ``list[str]`` configs coerce to {title, prompt} with title=content."""
|
||||
agent_id = "agent-cq-legacy"
|
||||
_write_agent_config(
|
||||
agent_id, name="助手", description="", common_questions=["如何上传文件?"]
|
||||
)
|
||||
|
||||
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
|
||||
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
|
||||
assert raw["common_questions"] == [
|
||||
{"title": "如何上传文件?", "prompt": "如何上传文件?"}
|
||||
]
|
||||
|
||||
|
||||
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
|
||||
|
||||
Reference in new issue
Block a user