Files

121 lines
7.7 KiB
Python

"""ORM model for custom skill ownership + publish state.
Note: skill content lives on disk under ``skills/custom/{name}/``; this table
only stores the metadata (owner, published flag, timestamps). The skill
``name`` doubles as the primary key because the filesystem path is already
keyed by name, and we require global name uniqueness across all owners.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class SkillRow(Base):
__tablename__ = "skills"
__table_args__ = (
Index("ix_skills_catalog_available_category_created", "available", "category", "created_at"),
)
# Global unique name — must match the on-disk folder name under skills/custom/.
# Hyphen-case or snake_case allowed; see deerflow.skills.validation.
name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Compact catalog projection fields. The list API reads these columns from
# MySQL and deliberately excludes the long-form ``detail`` text column.
category: Mapped[str] = mapped_column(String(16), nullable=False, default="custom", server_default="custom")
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="", server_default="")
license: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1"))
available: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1"), index=True)
use_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
patch_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
last_activity_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None)
state: Mapped[str] = mapped_column(String(16), nullable=False, default="active", server_default="active")
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))
featured: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("0"))
source: Mapped[str] = mapped_column(String(16), nullable=False, default="upload", server_default="upload")
source_mtime_ns: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default=text("0"))
detail_preview: Mapped[str | None] = mapped_column(String(1024), nullable=True, default=None)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
# Human-authored long-form detail/introduction, editable by the skill owner
# or an admin. Distinct from the skill ``description`` parsed from SKILL.md;
# kept in the DB so admins can annotate built-in skills without rewriting
# the shared on-disk SKILL.md file.
detail: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
# User-configured or LLM-generated Chinese display name. Kept in the DB (not
# 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)
# 常驻(免压缩):开启技能压缩后,标记此项的技能仍以全文形式注入系统提示词,而非
# 降级为索引 + search_skills 检索。存 DB(而非 extensions_config.json),内置技能
# 也能标记,且避免在某些部署里回写源码目录下的配置文件导致报错。
always_on: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0", index=True)
# 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="", server_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))
class SkillFavoriteRow(Base):
"""A user's "我的技能" membership for a skill they don't own.
Mirrors :class:`deerflow.persistence.agents.model.AgentFavoriteRow`: lets a
skill land in a user's "我的" list without the user owning it. Skills are
keyed by ``name`` (the on-disk folder / DB primary key), so this row
references ``skill_name`` rather than an opaque id. One row per
(user, skill); the unique constraint makes the grant idempotent.
"""
__tablename__ = "skill_favorites"
__table_args__ = (UniqueConstraint("user_id", "skill_name", name="uq_skill_favorites_user_skill"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
skill_name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# ``"user"`` = the user added it themselves (reserved for a future favorite
# button); ``"position"`` = auto-granted by the user's 岗位 (position) via
# the subscription sync engine. Sync only ever adds/removes ``"position"``
# rows, so a user's own picks are never clobbered.
origin: Mapped[str] = mapped_column(String(16), nullable=False, default="user", server_default="user")
# The position that granted this favorite (only set when ``origin='position'``).
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
class SkillDisplayAdapterRow(Base):
__tablename__ = "skill_display_adapters"
skill_name: Mapped[str] = mapped_column(String(64), primary_key=True)
# Store JSON as text for maximum MySQL compatibility. Some deployments still
# run MySQL variants where native JSON columns or unquoted names such as
# ``display`` are not accepted consistently.
display_config: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
sample_payload: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
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))
class SkillCatalogSnapshotRow(Base):
"""MySQL-backed projection of filesystem skill metadata for fast list APIs.
Skill source files remain the authoring format. The Gateway refreshes this
one-row projection during startup so homepage listing requests do not scan
every SKILL.md or read per-skill usage sidecars.
"""
__tablename__ = "skill_catalog_snapshots"
cache_key: Mapped[str] = mapped_column(String(32), primary_key=True)
catalog_json: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))