feat: 技能收藏与常驻(always-on)支持
- 新增 skill_favorites / skill_always_on 迁移与持久化模型字段 - skills 路由、存储、前端设置页支持收藏与常驻 - 调整摘要中间件与 lead agent 提示词 - 同步岗位 position_sync 及相关配置清理 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5ebbb82d66
commit
5182ea5d11
19 files changed
+472
-107
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Anchor,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ArrowDownUpIcon,
|
ArrowDownUpIcon,
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
@@ -77,6 +78,7 @@ import {
|
|||||||
useGenerateSkillNameZh,
|
useGenerateSkillNameZh,
|
||||||
useInstallSkillUpload,
|
useInstallSkillUpload,
|
||||||
useReconcileCustomSkills,
|
useReconcileCustomSkills,
|
||||||
|
useSetSkillAlwaysOn,
|
||||||
useSetSkillFeatured,
|
useSetSkillFeatured,
|
||||||
useSetSkillNameZh,
|
useSetSkillNameZh,
|
||||||
useSetSkillPinned,
|
useSetSkillPinned,
|
||||||
@@ -84,7 +86,11 @@ import {
|
|||||||
useSkills,
|
useSkills,
|
||||||
useValidateSkillUpload,
|
useValidateSkillUpload,
|
||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import { usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
import {
|
||||||
|
usePublishSquares,
|
||||||
|
useSkillCompressionSettings,
|
||||||
|
useViewMode,
|
||||||
|
} from "@/core/system-settings/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { formatDateTime } from "@/core/utils/datetime";
|
import { formatDateTime } from "@/core/utils/datetime";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
@@ -181,8 +187,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
const isAdmin = storedAuth?.system_role === "admin";
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
|
|
||||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||||
// Non-admins get their 岗位 (position) visibility applied (scope=mine): a
|
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
|
||||||
// position that binds skill tags restricts which skills they can see.
|
// member's 我的技能 (each such skill comes back with ``favorited: true``), so
|
||||||
|
// the 广场 always shows every visible skill and the 我的 tab includes the
|
||||||
|
// position-granted ones. ``scope`` is kept for API compatibility only.
|
||||||
const { skills, isLoading, error } = useSkills({
|
const { skills, isLoading, error } = useSkills({
|
||||||
search,
|
search,
|
||||||
tagIds,
|
tagIds,
|
||||||
@@ -217,7 +225,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
|||||||
if (isBuiltin || !!skill.published) {
|
if (isBuiltin || !!skill.published) {
|
||||||
all.push(skill);
|
all.push(skill);
|
||||||
}
|
}
|
||||||
if (isOwnedByMe) {
|
// "我的" = skills I own OR skills granted into my collection by my 岗位
|
||||||
|
// (position) — mirroring how favorited agents land in 我的智能体.
|
||||||
|
if (isOwnedByMe || !!skill.favorited) {
|
||||||
mine.push(skill);
|
mine.push(skill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,6 +611,8 @@ export function SkillRow({
|
|||||||
const { data: cardPublishSquares } = usePublishSquares();
|
const { data: cardPublishSquares } = usePublishSquares();
|
||||||
const setPinned = useSetSkillPinned();
|
const setPinned = useSetSkillPinned();
|
||||||
const setFeatured = useSetSkillFeatured();
|
const setFeatured = useSetSkillFeatured();
|
||||||
|
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||||
|
const { data: compression } = useSkillCompressionSettings();
|
||||||
const deleteSkill = useDeleteCustomSkill();
|
const deleteSkill = useDeleteCustomSkill();
|
||||||
const duplicateSkillMutation = useDuplicateSkill();
|
const duplicateSkillMutation = useDuplicateSkill();
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
@@ -620,6 +632,17 @@ export function SkillRow({
|
|||||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||||
const canManage = isCustom && (isOwned || isAdmin);
|
const canManage = isCustom && (isOwned || isAdmin);
|
||||||
const canTogglePublish = canManage && !isBuiltin;
|
const canTogglePublish = canManage && !isBuiltin;
|
||||||
|
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||||
|
const canSetAlwaysOn = isAdmin || isOwned;
|
||||||
|
|
||||||
|
async function handleToggleAlwaysOn(next: boolean) {
|
||||||
|
try {
|
||||||
|
await setAlwaysOn.mutateAsync({ skillName: skill.name, alwaysOn: next });
|
||||||
|
toast.success(next ? "已设为常驻(免压缩)" : "已取消常驻");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTogglePublished(next: boolean) {
|
async function handleTogglePublished(next: boolean) {
|
||||||
// Publishing: prompt for which square to publish into (when configured).
|
// Publishing: prompt for which square to publish into (when configured).
|
||||||
@@ -932,6 +955,27 @@ export function SkillRow({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{compression?.enabled && canSetAlwaysOn && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 shrink-0",
|
||||||
|
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||||||
|
)}
|
||||||
|
disabled={setAlwaysOn.isPending}
|
||||||
|
onClick={() => void handleToggleAlwaysOn(!skill.always_on)}
|
||||||
|
title={
|
||||||
|
skill.always_on
|
||||||
|
? "取消常驻(将被压缩,改由 search_skills 检索)"
|
||||||
|
: "设为常驻(免压缩,始终全文注入提示词)"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Anchor
|
||||||
|
className={cn("size-3.5", skill.always_on && "fill-current")}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canTogglePublish && (
|
{canTogglePublish && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const POSITION_RESOURCE_LABELS: Record<PositionResourceType, string> = {
|
|||||||
|
|
||||||
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
export const POSITION_RESOURCE_HINT: Record<PositionResourceType, string> = {
|
||||||
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
agent: "不配置标签 = 默认加载全部内置智能体;配置后只加载所选标签下的智能体。",
|
||||||
skill: "不配置标签 = 可见全部技能;配置后只可见所选标签下的技能。",
|
skill: "不配置标签 = 不向「我的技能」添加任何技能;配置后将所选标签下的技能加入成员的「我的技能」(技能广场仍展示全部技能)。",
|
||||||
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
scheduled_task: "不配置标签 = 不订阅任何定时任务;配置后订阅所选标签下的定时任务。",
|
||||||
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
|
recommended_question: "不配置标签 = 展示全部推荐问题;配置后只展示所选标签下的推荐问题(聊天框上方的推荐问题)。",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export interface Skill {
|
|||||||
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
/** Publish square (发布广场) the skill is filed under once published. "" = default square. */
|
||||||
square_id?: string;
|
square_id?: string;
|
||||||
builtin?: boolean;
|
builtin?: boolean;
|
||||||
|
// True when this skill is in the caller's 我的技能 collection — e.g. granted by
|
||||||
|
// their 岗位 (position). Owned skills are detected separately via owner_user_id.
|
||||||
|
favorited?: boolean;
|
||||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||||
detail?: string | null;
|
detail?: string | null;
|
||||||
|
|||||||
@@ -360,16 +360,24 @@ shown to users ARE the **recommended questions** (`recommended_questions` table)
|
|||||||
extended with `recommended_question`).
|
extended with `recommended_question`).
|
||||||
|
|
||||||
**Sync engine** (`deerflow/services/position_sync.py`, wired as
|
**Sync engine** (`deerflow/services/position_sync.py`, wired as
|
||||||
`app.state.position_sync`). `PositionSyncService.resolve_visible(user, type)`
|
`app.state.position_sync`). `sync_user(user)` **materializes** three resource
|
||||||
returns `set | None` (None = unrestricted) for the **query-time** visibility
|
types into the existing "我的" tables, idempotently:
|
||||||
types (`skill`, `recommended_question`). `sync_user(user)` **materializes** the
|
|
||||||
other two into the existing "我的" tables, idempotently:
|
|
||||||
- agents → `agent_favorites` rows with `origin='position'`
|
- agents → `agent_favorites` rows with `origin='position'`
|
||||||
|
- **skills → `skill_favorites` rows with `origin='position'`** (table
|
||||||
|
`SkillFavoriteRow`, migration `20260616_02`, auto-created by `create_all`)
|
||||||
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
|
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
|
||||||
|
|
||||||
|
Skills behave **exactly like agents**: a position only *adds* its tagged skills
|
||||||
|
to the member's 我的技能 — the browse-all 广场 is never restricted. (This replaced
|
||||||
|
the earlier query-time visibility filter, which wrongly shrank the square to the
|
||||||
|
bound skill.) `PositionSyncService.resolve_visible(user, type)` returns
|
||||||
|
`set | None` (None = unrestricted) and is now used **only** for the one remaining
|
||||||
|
query-time type, `recommended_question`.
|
||||||
|
|
||||||
Sync only ever adds/removes `origin='position'` rows, so a user's own
|
Sync only ever adds/removes `origin='position'` rows, so a user's own
|
||||||
favorites/subscriptions are never clobbered. Default sets: agents = built-ins
|
favorites/subscriptions are never clobbered. Default sets (no binding for a
|
||||||
(`user_id IS NULL`); scheduled tasks = empty; skills/questions = unrestricted.
|
type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays
|
||||||
|
full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted.
|
||||||
`origin`/`position_id` columns were added to `agent_favorites` and
|
`origin`/`position_id` columns were added to `agent_favorites` and
|
||||||
`scheduled_task_subscriptions` — see migration `20260616_01` +
|
`scheduled_task_subscriptions` — see migration `20260616_01` +
|
||||||
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
|
`scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these
|
||||||
@@ -390,8 +398,11 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label
|
|||||||
(`strategy-components/Header.tsx` user cluster, via `useMyPosition`). Related
|
(`strategy-components/Header.tsx` user cluster, via `useMyPosition`). Related
|
||||||
changes: `GET /api/recommended-questions` is now
|
changes: `GET /api/recommended-questions` is now
|
||||||
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
filtered by the caller's position (`?all=1` bypasses for admin management) and
|
||||||
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`;
|
admin can tag questions via `/api/tags/assignments/recommended_question/{id}`.
|
||||||
`GET /api/skills?scope=mine` applies skill visibility. Frontend: `core/positions`
|
`GET /api/skills` no longer restricts by position; each skill carries a
|
||||||
|
`favorited` flag (true = in the caller's 我的技能, e.g. position-granted) so the
|
||||||
|
skills page's 我的 tab includes owned ∪ position-granted skills while the 广场
|
||||||
|
stays complete. Frontend: `core/positions`
|
||||||
+ admin `PositionManagementPage` (侧边栏「综合管理 → 岗位管理」 + 右下角设置菜单).
|
+ admin `PositionManagementPage` (侧边栏「综合管理 → 岗位管理」 + 右下角设置菜单).
|
||||||
Recommended questions stay rendered **above** the chat input (their original
|
Recommended questions stay rendered **above** the chat input (their original
|
||||||
position), just filtered by the caller's position. Tests:
|
position), just filtered by the caller's position. Tests:
|
||||||
@@ -499,11 +510,18 @@ prompt body and surfaces the rest on demand via keyword retrieval.
|
|||||||
default 5, `keep_index_in_prompt` default true). Read/written by
|
default 5, `keep_index_in_prompt` default true). Read/written by
|
||||||
`GET`/`PUT /api/system-settings/skill-compression` (GET open to any user — the
|
`GET`/`PUT /api/system-settings/skill-compression` (GET open to any user — the
|
||||||
skill detail page reads it; PUT admin-only, refreshes the prompt cache).
|
skill detail page reads it; PUT admin-only, refreshes the prompt cache).
|
||||||
- **Per-skill 常驻 flag** — `extensions_config.json → skills.{name}.always_on`
|
- **Per-skill 常驻 flag** — stored in the **DB**: `SkillRow.always_on`
|
||||||
(`SkillStateConfig.always_on`, alongside `enabled`). Surfaced on
|
(`Boolean`, migration `20260616_03`, idempotent SQL in
|
||||||
`Skill.always_on` (populated in `load_skills`) and `SkillResponse.always_on`;
|
`column_additions_mysql.sql`), **not** in `extensions_config.json` (the
|
||||||
toggled via `PUT /api/skills/{name}/always-on`. `update_skill` /
|
source-tree config file is read-only / unsafe to rewrite in some deployments,
|
||||||
`update_skill_display` preserve it when rewriting the config entry.
|
and built-in skills carry no file entry). Threaded through `SkillStore`
|
||||||
|
create/ensure_legacy/update/update_admin + `list_always_on_names()` exactly
|
||||||
|
like `name_zh`/`detail`; surfaced on `SkillResponse.always_on` from the
|
||||||
|
ownership record in `_skill_to_response`. Toggled via
|
||||||
|
`PUT /api/skills/{name}/always-on` (custom skill → owner or admin; built-in /
|
||||||
|
public → admin only, materializes a row on demand via `ensure_legacy`). The
|
||||||
|
prompt builder stamps `Skill.always_on` from `list_always_on_names()` via a
|
||||||
|
sync DB bridge (`_apply_always_on` in `lead_agent/prompt.py`).
|
||||||
- **Prompt split** — `get_skills_prompt_section` (`lead_agent/prompt.py`) reads
|
- **Prompt split** — `get_skills_prompt_section` (`lead_agent/prompt.py`) reads
|
||||||
the compression settings (skipped for restricted custom agents — they already
|
the compression settings (skipped for restricted custom agents — they already
|
||||||
have a curated allowlist). When **off**, behaviour is unchanged (all enabled
|
have a curated allowlist). When **off**, behaviour is unchanged (all enabled
|
||||||
@@ -688,7 +706,7 @@ Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The sha
|
|||||||
|
|
||||||
**`extensions_config.json`**:
|
**`extensions_config.json`**:
|
||||||
- `mcpServers` - Map of server name ? config (enabled, type, command, args, env, url, headers, oauth, description)
|
- `mcpServers` - Map of server name ? config (enabled, type, command, args, env, url, headers, oauth, description)
|
||||||
- `skills` - Map of skill name ? state (enabled, always_on)
|
- `skills` - Map of skill name ? state (enabled). 常驻(免压缩)/always_on lives in the `skills` DB table, not here.
|
||||||
|
|
||||||
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class SkillResponse(BaseModel):
|
|||||||
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
|
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.")
|
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)")
|
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
|
||||||
|
favorited: bool = Field(default=False, description="True when this skill is in the caller's 我的技能 collection (e.g. granted by their 岗位/position). Owned skills are detected separately via owner_user_id.")
|
||||||
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
|
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")
|
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
|
||||||
display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor")
|
display_sample: dict[str, Any] | None = Field(default=None, alias="displaySample", description="Persisted sample output used by the display adapter editor")
|
||||||
@@ -280,6 +281,7 @@ def _skill_to_response(
|
|||||||
*,
|
*,
|
||||||
display_override: dict[str, Any] | None | object = _UNSET,
|
display_override: dict[str, Any] | None | object = _UNSET,
|
||||||
display_sample: dict[str, Any] | None = None,
|
display_sample: dict[str, Any] | None = None,
|
||||||
|
favorited: bool = False,
|
||||||
) -> SkillResponse:
|
) -> SkillResponse:
|
||||||
"""Convert a Skill object to a SkillResponse.
|
"""Convert a Skill object to a SkillResponse.
|
||||||
|
|
||||||
@@ -317,6 +319,9 @@ def _skill_to_response(
|
|||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
name_zh: str | None = None
|
name_zh: str | None = None
|
||||||
square_id = ""
|
square_id = ""
|
||||||
|
# 常驻(免压缩) is stored in the DB (SkillRow.always_on), surfaced via the
|
||||||
|
# ownership record — never read from the on-disk extensions_config.json.
|
||||||
|
always_on = False
|
||||||
if ownership is not None:
|
if ownership is not None:
|
||||||
owner_user_id = ownership.get("owner_user_id")
|
owner_user_id = ownership.get("owner_user_id")
|
||||||
published = bool(ownership.get("published", False))
|
published = bool(ownership.get("published", False))
|
||||||
@@ -324,6 +329,7 @@ def _skill_to_response(
|
|||||||
detail = ownership.get("detail")
|
detail = ownership.get("detail")
|
||||||
name_zh = ownership.get("name_zh")
|
name_zh = ownership.get("name_zh")
|
||||||
square_id = str(ownership.get("square_id") or "")
|
square_id = str(ownership.get("square_id") or "")
|
||||||
|
always_on = bool(ownership.get("always_on", False))
|
||||||
|
|
||||||
display = None if display_override is _UNSET else display_override
|
display = None if display_override is _UNSET else display_override
|
||||||
if display_override is _UNSET:
|
if display_override is _UNSET:
|
||||||
@@ -341,7 +347,7 @@ def _skill_to_response(
|
|||||||
license=skill.license,
|
license=skill.license,
|
||||||
category=skill.category,
|
category=skill.category,
|
||||||
enabled=skill.enabled,
|
enabled=skill.enabled,
|
||||||
always_on=skill.always_on,
|
always_on=always_on,
|
||||||
use_count=entry.get("use_count", 0),
|
use_count=entry.get("use_count", 0),
|
||||||
view_count=entry.get("view_count", 0),
|
view_count=entry.get("view_count", 0),
|
||||||
patch_count=entry.get("patch_count", 0),
|
patch_count=entry.get("patch_count", 0),
|
||||||
@@ -355,6 +361,7 @@ def _skill_to_response(
|
|||||||
published=published,
|
published=published,
|
||||||
square_id=square_id,
|
square_id=square_id,
|
||||||
builtin=builtin,
|
builtin=builtin,
|
||||||
|
favorited=favorited,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
display=display,
|
display=display,
|
||||||
displaySample=display_sample,
|
displaySample=display_sample,
|
||||||
@@ -810,6 +817,15 @@ async def list_skills(
|
|||||||
ownership = {record["name"]: record for record in ownership_records}
|
ownership = {record["name"]: record for record in ownership_records}
|
||||||
else:
|
else:
|
||||||
ownership = await _build_ownership_index(skill_store, user_id)
|
ownership = await _build_ownership_index(skill_store, user_id)
|
||||||
|
# Skills the caller's 岗位 (position) granted into their "我的技能"
|
||||||
|
# collection. Admins browse everything, so the badge is moot for them.
|
||||||
|
favorite_names: set[str] = set()
|
||||||
|
if not admin:
|
||||||
|
try:
|
||||||
|
favorite_names = await skill_store.list_favorite_skill_names(user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("skill favorites lookup failed", exc_info=True)
|
||||||
|
favorite_names = set()
|
||||||
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
display_adapters = await _list_skill_display_adapters([skill.name for skill in all_skills])
|
||||||
responses: list[SkillResponse] = []
|
responses: list[SkillResponse] = []
|
||||||
for skill in all_skills:
|
for skill in all_skills:
|
||||||
@@ -825,22 +841,30 @@ async def list_skills(
|
|||||||
ownership.get(skill.name),
|
ownership.get(skill.name),
|
||||||
display_override=_display_override_from_adapter(adapter),
|
display_override=_display_override_from_adapter(adapter),
|
||||||
display_sample=adapter.get("sample") if adapter else None,
|
display_sample=adapter.get("sample") if adapter else None,
|
||||||
|
favorited=skill.name in favorite_names,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
record = ownership.get(skill.name)
|
record = ownership.get(skill.name)
|
||||||
if record is None:
|
if record is None:
|
||||||
if not admin:
|
if admin:
|
||||||
|
# Admin sees every custom skill on disk even without an
|
||||||
|
# ownership row (legacy / orphaned), so they can clean it up.
|
||||||
|
record = None
|
||||||
|
elif skill.name in favorite_names:
|
||||||
|
# A position granted this skill even though it isn't
|
||||||
|
# otherwise visible to the user (e.g. an unpublished custom
|
||||||
|
# skill). Surface it so it lands in their 我的技能.
|
||||||
|
record = await skill_store.get_any(skill.name)
|
||||||
|
else:
|
||||||
continue
|
continue
|
||||||
# Admin sees every custom skill on disk even without an
|
|
||||||
# ownership row (legacy / orphaned), so they can clean it up.
|
|
||||||
record = None
|
|
||||||
responses.append(
|
responses.append(
|
||||||
_skill_to_response(
|
_skill_to_response(
|
||||||
skill,
|
skill,
|
||||||
record,
|
record,
|
||||||
display_override=_display_override_from_adapter(adapter),
|
display_override=_display_override_from_adapter(adapter),
|
||||||
display_sample=adapter.get("sample") if adapter else None,
|
display_sample=adapter.get("sample") if adapter else None,
|
||||||
|
favorited=skill.name in favorite_names,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
responses = await _attach_and_filter_tags(tag_store, responses, search, tag_ids)
|
||||||
@@ -855,19 +879,12 @@ async def list_skills(
|
|||||||
for r in responses
|
for r in responses
|
||||||
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))
|
||||||
]
|
]
|
||||||
# 岗位 (position) visibility: when the caller asks for "我的技能",
|
# 岗位 (position) no longer *restricts* the skill list: a position grants
|
||||||
# restrict to the skills their position grants. ``None`` from the sync
|
# skills by materializing them into the member's 我的技能 (the ``favorited``
|
||||||
# service = unrestricted (no skill tag binding → all skills visible).
|
# flag above + the ``skill_favorites`` table, synced by PositionSyncService),
|
||||||
if scope == "mine" and not admin:
|
# mirroring how agents are added to 我的智能体. The browse-all 广场 therefore
|
||||||
sync = getattr(request.app.state, "position_sync", None)
|
# always shows every visible skill — the ``scope`` param is kept for API
|
||||||
if sync is not None:
|
# compatibility but intentionally does not filter here.
|
||||||
try:
|
|
||||||
visible = await sync.resolve_visible(user_id, "skill")
|
|
||||||
except Exception:
|
|
||||||
logger.debug("skill visibility resolve failed", exc_info=True)
|
|
||||||
visible = None
|
|
||||||
if visible is not None:
|
|
||||||
responses = [r for r in responses if r.name in visible]
|
|
||||||
responses = _sort_skill_responses(responses, sort)
|
responses = _sort_skill_responses(responses, sort)
|
||||||
return SkillsListResponse(skills=responses)
|
return SkillsListResponse(skills=responses)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -2194,7 +2211,6 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
current_skill_config = extensions_config.skills.get(skill_name)
|
current_skill_config = extensions_config.skills.get(skill_name)
|
||||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||||
enabled=request.enabled,
|
enabled=request.enabled,
|
||||||
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
|
|
||||||
display=current_skill_config.display if current_skill_config else None,
|
display=current_skill_config.display if current_skill_config else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2229,41 +2245,50 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
|
|||||||
summary="Update Skill 常驻(免压缩) Flag",
|
summary="Update Skill 常驻(免压缩) Flag",
|
||||||
description="Mark a skill as 常驻(免压缩) so it is injected full-text into the system prompt even when skill compression is enabled.",
|
description="Mark a skill as 常驻(免压缩) so it is injected full-text into the system prompt even when skill compression is enabled.",
|
||||||
)
|
)
|
||||||
async def update_skill_always_on(skill_name: str, request: SkillAlwaysOnUpdateRequest, config: AppConfig = Depends(get_config)) -> SkillResponse:
|
async def update_skill_always_on(
|
||||||
|
skill_name: str,
|
||||||
|
request: SkillAlwaysOnUpdateRequest,
|
||||||
|
http_request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
skill_store: SkillStore = Depends(get_skill_store),
|
||||||
|
) -> SkillResponse:
|
||||||
|
"""Persist the 常驻(免压缩) flag in the **DB** (skills table), not in
|
||||||
|
extensions_config.json — the source-tree config file is read-only / unsafe to
|
||||||
|
rewrite in some deployments, and built-in skills carry no file entry anyway.
|
||||||
|
Mirrors the detail/name_zh endpoints: custom skill → owner or admin; built-in
|
||||||
|
/ public skill → admin only (materializes a row on demand)."""
|
||||||
try:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
|
user_id = _current_user_id(http_request)
|
||||||
|
is_admin = _is_admin_user(http_request)
|
||||||
|
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
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)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
|
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
|
||||||
config_path = ExtensionsConfig.resolve_config_path()
|
if skill.category == SkillCategory.CUSTOM:
|
||||||
if config_path is None:
|
_record, mode = await _get_editable_skill_or_raise(skill_store, skill_name, user_id, is_admin=is_admin)
|
||||||
config_path = Path.cwd().parent / "extensions_config.json"
|
if mode == "admin":
|
||||||
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
|
updated = await skill_store.update_admin(skill_name, {"always_on": request.always_on})
|
||||||
|
else:
|
||||||
|
updated = await skill_store.update(skill_name, user_id, {"always_on": request.always_on})
|
||||||
|
else:
|
||||||
|
# Built-in / public skill — admin only; no DB row by default.
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admin users can change a built-in skill's 常驻 flag")
|
||||||
|
if await skill_store.get_any(skill_name) is None:
|
||||||
|
await skill_store.ensure_legacy({"name": skill_name, "owner_user_id": None, "published": True})
|
||||||
|
updated = await skill_store.update_admin(skill_name, {"always_on": request.always_on})
|
||||||
|
|
||||||
extensions_config = get_extensions_config()
|
if updated is None:
|
||||||
current_skill_config = extensions_config.skills.get(skill_name)
|
raise HTTPException(status_code=500, detail="Failed to persist skill 常驻 flag")
|
||||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
|
||||||
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
|
|
||||||
always_on=request.always_on,
|
|
||||||
display=current_skill_config.display if current_skill_config else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
# always_on lives in the system prompt's skill injection — refresh the cache.
|
||||||
json.dump(_dump_extensions_config(extensions_config), f, indent=2)
|
|
||||||
|
|
||||||
reload_extensions_config()
|
|
||||||
await refresh_skills_system_prompt_cache_async()
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
logger.info("Skill '%s' always_on updated to %s", skill_name, request.always_on)
|
||||||
updated_skill = next((s for s in skills if s.name == skill_name), None)
|
return _skill_to_response(skill, updated)
|
||||||
if updated_skill is None:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after update")
|
|
||||||
|
|
||||||
logger.info(f"Skill '{skill_name}' always_on updated to {request.always_on}")
|
|
||||||
return _skill_to_response(updated_skill)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -2300,7 +2325,6 @@ async def update_skill_display(
|
|||||||
current_skill_config = extensions_config.skills.get(skill_name)
|
current_skill_config = extensions_config.skills.get(skill_name)
|
||||||
extensions_config.skills[skill_name] = SkillStateConfig(
|
extensions_config.skills[skill_name] = SkillStateConfig(
|
||||||
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
|
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
|
||||||
always_on=current_skill_config.always_on if current_skill_config else skill.always_on,
|
|
||||||
display=request.display,
|
display=request.display,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+52
-2
@@ -86,8 +86,58 @@ def _drop_archived_skills(skills: list[Skill]) -> list[Skill]:
|
|||||||
return skills
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def _run_coro_blocking(coro):
|
||||||
|
"""Run an async coroutine to completion from sync code.
|
||||||
|
|
||||||
|
Mirrors ``skill_tools._run_coro_blocking``: when already inside a running
|
||||||
|
event loop (e.g. prompt built during an async agent run), hand the coroutine
|
||||||
|
to a fresh loop in a worker thread; otherwise run it directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
in_loop = True
|
||||||
|
except RuntimeError:
|
||||||
|
in_loop = False
|
||||||
|
|
||||||
|
if in_loop:
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
return pool.submit(asyncio.run, coro).result()
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_always_on_names() -> set[str]:
|
||||||
|
"""Names marked 常驻(免压缩), read from the **DB** (skills table).
|
||||||
|
|
||||||
|
Returns an empty set on any failure / no-DB deployment, so compression then
|
||||||
|
simply treats every skill as compressible (safe default).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from deerflow.persistence.engine import get_session_factory
|
||||||
|
from deerflow.persistence.skills import make_skill_store
|
||||||
|
|
||||||
|
store = make_skill_store(get_session_factory())
|
||||||
|
return set(_run_coro_blocking(store.list_always_on_names()))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to load always_on skill names from DB", exc_info=True)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_always_on(skills: list[Skill]) -> list[Skill]:
|
||||||
|
"""Stamp ``Skill.always_on`` from the DB-backed 常驻 set (in place)."""
|
||||||
|
try:
|
||||||
|
always_on = _load_always_on_names()
|
||||||
|
if always_on:
|
||||||
|
for skill in skills:
|
||||||
|
skill.always_on = skill.name in always_on
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to apply always_on flags to skills", exc_info=True)
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
def _load_enabled_skills_sync() -> list[Skill]:
|
def _load_enabled_skills_sync() -> list[Skill]:
|
||||||
return _drop_archived_skills(list(get_or_new_skill_storage().load_skills(enabled_only=True)))
|
return _apply_always_on(_drop_archived_skills(list(get_or_new_skill_storage().load_skills(enabled_only=True))))
|
||||||
|
|
||||||
|
|
||||||
def _start_enabled_skills_refresh_thread() -> None:
|
def _start_enabled_skills_refresh_thread() -> None:
|
||||||
@@ -188,7 +238,7 @@ def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[
|
|||||||
"""
|
"""
|
||||||
if app_config is None:
|
if app_config is None:
|
||||||
return _get_enabled_skills()
|
return _get_enabled_skills()
|
||||||
return _drop_archived_skills(list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True)))
|
return _apply_always_on(_drop_archived_skills(list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))))
|
||||||
|
|
||||||
|
|
||||||
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
def _skill_mutability_label(category: SkillCategory | str) -> str:
|
||||||
|
|||||||
+41
-2
@@ -314,16 +314,36 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
|
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
|
||||||
cached = self._get_cache_entry(thread_id) if thread_id else None
|
cached = self._get_cache_entry(thread_id) if thread_id else None
|
||||||
present_ids = {m.id for m in messages}
|
present_ids = {m.id for m in messages}
|
||||||
if cached is not None and cached.reusable and cached.summarized_ids <= present_ids:
|
cache_usable = cached is not None and cached.reusable and cached.summarized_ids <= present_ids
|
||||||
|
if cache_usable:
|
||||||
preserved = [m for m in messages if m.id not in cached.summarized_ids]
|
preserved = [m for m in messages if m.id not in cached.summarized_ids]
|
||||||
effective = [self._summary_message(cached.summary_text), *preserved]
|
effective = [self._summary_message(cached.summary_text), *preserved]
|
||||||
if not self._should_summarize(effective, self.token_counter(effective)):
|
# Decide re-summarization on the *counted* size of the compressed
|
||||||
|
# context only — NOT ``_should_summarize`` — because that folds in the
|
||||||
|
# last AIMessage's reported ``usage_metadata`` (system prompt + tool
|
||||||
|
# schemas, fixed overhead compression can't shrink). Using it here
|
||||||
|
# would re-summarize on every single turn once that overhead alone
|
||||||
|
# approaches the trigger.
|
||||||
|
if not self._exceeds_trigger_by_count(effective):
|
||||||
# Refresh LRU recency and reuse without an LLM call.
|
# Refresh LRU recency and reuse without an LLM call.
|
||||||
self._touch_cache_entry(thread_id)
|
self._touch_cache_entry(thread_id)
|
||||||
return ("reuse", effective)
|
return ("reuse", effective)
|
||||||
# New messages have pushed the compressed context back over the
|
# New messages have pushed the compressed context back over the
|
||||||
# threshold — fall through and re-summarize a larger prefix.
|
# threshold — fall through and re-summarize a larger prefix.
|
||||||
|
|
||||||
|
# Genuine (blocking) LLM summarize path. Log *why* we are paying for it so
|
||||||
|
# an "it compacts every turn" report is diagnosable straight from the
|
||||||
|
# backend log: cache=miss → thread_id/key problem; unusable → message-id
|
||||||
|
# instability; stale → preserved genuinely grew back over the trigger.
|
||||||
|
cache_state = "stale" if cache_usable else ("miss" if cached is None else "unusable")
|
||||||
|
logger.info(
|
||||||
|
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
|
||||||
|
thread_id,
|
||||||
|
cache_state,
|
||||||
|
self.token_counter(messages),
|
||||||
|
len(messages),
|
||||||
|
)
|
||||||
|
|
||||||
cutoff_index = self._determine_cutoff_index(messages)
|
cutoff_index = self._determine_cutoff_index(messages)
|
||||||
if cutoff_index <= 0:
|
if cutoff_index <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -386,6 +406,25 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
|
while len(self._summary_cache) > _SUMMARY_CACHE_MAX_THREADS:
|
||||||
self._summary_cache.popitem(last=False)
|
self._summary_cache.popitem(last=False)
|
||||||
|
|
||||||
|
def _exceeds_trigger_by_count(self, messages: list[AnyMessage]) -> bool:
|
||||||
|
"""Trigger check using only the *counted* size of ``messages``.
|
||||||
|
|
||||||
|
Deliberately ignores the stock middleware's reported-``usage_metadata``
|
||||||
|
heuristic: that number includes fixed system-prompt + tool-schema
|
||||||
|
overhead which compression can never shrink, so relying on it to decide
|
||||||
|
*re*-summarization makes a long, tool-heavy thread re-compact on every
|
||||||
|
turn. The first-ever decision to compress still uses the full
|
||||||
|
``_should_summarize`` (reported usage is a fair signal there); only the
|
||||||
|
cache-reuse-vs-recompact decision uses this stricter, content-only check.
|
||||||
|
"""
|
||||||
|
total = self.token_counter(messages)
|
||||||
|
for kind, value in getattr(self, "_trigger_conditions", []):
|
||||||
|
if kind == "messages" and len(messages) >= value:
|
||||||
|
return True
|
||||||
|
if kind == "tokens" and total >= value:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _apply_stickiness_headroom(self, messages: list[AnyMessage], cutoff_index: int) -> int:
|
def _apply_stickiness_headroom(self, messages: list[AnyMessage], cutoff_index: int) -> int:
|
||||||
"""Advance ``cutoff_index`` (keep fewer recent messages) so the preserved
|
"""Advance ``cutoff_index`` (keep fewer recent messages) so the preserved
|
||||||
suffix lands below every trigger threshold with headroom.
|
suffix lands below every trigger threshold with headroom.
|
||||||
|
|||||||
@@ -910,7 +910,6 @@ class DeerFlowClient:
|
|||||||
current_skill_config = extensions_config.skills.get(name)
|
current_skill_config = extensions_config.skills.get(name)
|
||||||
extensions_config.skills[name] = SkillStateConfig(
|
extensions_config.skills[name] = SkillStateConfig(
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
always_on=current_skill_config.always_on if current_skill_config else False,
|
|
||||||
display=current_skill_config.display if current_skill_config else None,
|
display=current_skill_config.display if current_skill_config else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
-19
@@ -161,10 +161,6 @@ class SkillStateConfig(BaseModel):
|
|||||||
"""Configuration for a single skill's state."""
|
"""Configuration for a single skill's state."""
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
enabled: bool = Field(default=True, description="Whether this skill is enabled")
|
||||||
always_on: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="常驻(免压缩):开启技能压缩后,本技能仍以全文形式注入系统提示词,而非降级为索引+search_skills 检索",
|
|
||||||
)
|
|
||||||
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
|
||||||
|
|
||||||
|
|
||||||
@@ -317,21 +313,6 @@ class ExtensionsConfig(BaseModel):
|
|||||||
return skill_category in ("public", "custom")
|
return skill_category in ("public", "custom")
|
||||||
return skill_config.enabled
|
return skill_config.enabled
|
||||||
|
|
||||||
def is_skill_always_on(self, skill_name: str) -> bool:
|
|
||||||
"""Check if a skill is marked 常驻(免压缩).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skill_name: Name of the skill
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the skill should bypass skill compression, False otherwise
|
|
||||||
(default for skills without an explicit config entry).
|
|
||||||
"""
|
|
||||||
skill_config = self.skills.get(skill_name)
|
|
||||||
if skill_config is None:
|
|
||||||
return False
|
|
||||||
return skill_config.always_on
|
|
||||||
|
|
||||||
|
|
||||||
_extensions_config: ExtensionsConfig | None = None
|
_extensions_config: ExtensionsConfig | None = None
|
||||||
|
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""我的技能 (skill favorites) table.
|
||||||
|
|
||||||
|
New table ``skill_favorites`` — the per-user "我的技能" collection, mirroring
|
||||||
|
``agent_favorites``. A 岗位 (position) materializes its skill tag bindings into
|
||||||
|
this table (``origin='position'``) so granted skills land in the member's 我的
|
||||||
|
list while the browse-all 广场 stays complete.
|
||||||
|
|
||||||
|
Note: the running app also auto-creates this table via ``create_all`` on
|
||||||
|
startup (new tables are always created, even on existing DBs); this migration
|
||||||
|
keeps the alembic history consistent per the repo convention (see
|
||||||
|
backend/CLAUDE.md → Schema Migrations).
|
||||||
|
|
||||||
|
Revision ID: 20260616_02
|
||||||
|
Revises: 20260616_01
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260616_02"
|
||||||
|
down_revision = "20260616_01"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(table_name: str) -> bool:
|
||||||
|
return table_name in inspect(op.get_bind()).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _has_table("skill_favorites"):
|
||||||
|
op.create_table(
|
||||||
|
"skill_favorites",
|
||||||
|
sa.Column("id", sa.String(64), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.String(64), nullable=False),
|
||||||
|
sa.Column("skill_name", sa.String(64), nullable=False),
|
||||||
|
sa.Column("origin", sa.String(16), nullable=False, server_default="user"),
|
||||||
|
sa.Column("position_id", sa.String(64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "skill_name", name="uq_skill_favorites_user_skill"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_skill_favorites_user_id", "skill_favorites", ["user_id"])
|
||||||
|
op.create_index("ix_skill_favorites_skill_name", "skill_favorites", ["skill_name"])
|
||||||
|
op.create_index("ix_skill_favorites_position_id", "skill_favorites", ["position_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_table("skill_favorites"):
|
||||||
|
op.drop_table("skill_favorites")
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add always_on (常驻/免压缩) column to skills table.
|
||||||
|
|
||||||
|
Revision ID: 20260616_03
|
||||||
|
Revises: 20260616_02
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260616_03"
|
||||||
|
down_revision = "20260616_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 upgrade() -> None:
|
||||||
|
# ``skills`` may not exist yet on a brand-new DB — create_all handles that
|
||||||
|
# case with the column already present, so only patch existing tables.
|
||||||
|
if _has_table("skills") and not _has_column("skills", "always_on"):
|
||||||
|
op.add_column(
|
||||||
|
"skills",
|
||||||
|
sa.Column("always_on", sa.Boolean(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _has_column("skills", "always_on"):
|
||||||
|
op.drop_column("skills", "always_on")
|
||||||
+2
-1
@@ -60,7 +60,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
ScheduledTaskSubscriptionRow,
|
ScheduledTaskSubscriptionRow,
|
||||||
SchedulerThreadRow,
|
SchedulerThreadRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillRow
|
from deerflow.persistence.skills.model import SkillDisplayAdapterRow, SkillFavoriteRow, SkillRow
|
||||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||||
@@ -110,6 +110,7 @@ __all__ = [
|
|||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"SkillRow",
|
"SkillRow",
|
||||||
"SkillDisplayAdapterRow",
|
"SkillDisplayAdapterRow",
|
||||||
|
"SkillFavoriteRow",
|
||||||
"TagAssignmentRow",
|
"TagAssignmentRow",
|
||||||
"TagRow",
|
"TagRow",
|
||||||
"EmbedSessionRow",
|
"EmbedSessionRow",
|
||||||
|
|||||||
@@ -85,3 +85,23 @@ class SkillStore(ABC):
|
|||||||
DB round-trip per candidate skill.
|
DB round-trip per candidate skill.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
"""Return the set of skill names marked 常驻(免压缩) (``always_on = True``).
|
||||||
|
|
||||||
|
Batch query used by the prompt builder to decide which skills bypass
|
||||||
|
skill compression — one query instead of a per-skill round-trip.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
"""Return the skill names in ``user_id``'s "我的技能" collection.
|
||||||
|
|
||||||
|
These are skills the user does not own but that landed in their list —
|
||||||
|
currently only via their 岗位 (position) sync (``origin='position'``).
|
||||||
|
Owned skills are NOT included here (the caller detects those via
|
||||||
|
``owner_user_id``).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
+18
@@ -20,12 +20,15 @@ def _copy(record: dict[str, Any] | None) -> dict[str, Any] | None:
|
|||||||
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
|
def _with_derived(record: dict[str, Any]) -> dict[str, Any]:
|
||||||
out = dict(record)
|
out = dict(record)
|
||||||
out["builtin"] = out.get("owner_user_id") is None
|
out["builtin"] = out.get("owner_user_id") is None
|
||||||
|
out.setdefault("always_on", False)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
class MemorySkillStore(SkillStore):
|
class MemorySkillStore(SkillStore):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._skills: dict[str, dict[str, Any]] = {}
|
self._skills: dict[str, dict[str, Any]] = {}
|
||||||
|
# user_id -> {skill_name}
|
||||||
|
self._favorites: dict[str, set[str]] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
|
def _is_visible(record: dict[str, Any], user_id: str) -> bool:
|
||||||
@@ -71,6 +74,7 @@ class MemorySkillStore(SkillStore):
|
|||||||
"published": bool(data.get("published", False)),
|
"published": bool(data.get("published", False)),
|
||||||
"detail": data.get("detail"),
|
"detail": data.get("detail"),
|
||||||
"name_zh": data.get("name_zh"),
|
"name_zh": data.get("name_zh"),
|
||||||
|
"always_on": bool(data.get("always_on", False)),
|
||||||
"square_id": str(data.get("square_id") or ""),
|
"square_id": str(data.get("square_id") or ""),
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"updated_at": _now(),
|
"updated_at": _now(),
|
||||||
@@ -107,6 +111,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
record["name_zh"] = data["name_zh"]
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
record["always_on"] = bool(data["always_on"])
|
||||||
if "square_id" in data:
|
if "square_id" in data:
|
||||||
record["square_id"] = str(data["square_id"] or "")
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
@@ -124,6 +130,8 @@ class MemorySkillStore(SkillStore):
|
|||||||
record["detail"] = data["detail"]
|
record["detail"] = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
record["name_zh"] = data["name_zh"]
|
record["name_zh"] = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
record["always_on"] = bool(data["always_on"])
|
||||||
if "square_id" in data:
|
if "square_id" in data:
|
||||||
record["square_id"] = str(data["square_id"] or "")
|
record["square_id"] = str(data["square_id"] or "")
|
||||||
record["updated_at"] = _now()
|
record["updated_at"] = _now()
|
||||||
@@ -153,3 +161,13 @@ class MemorySkillStore(SkillStore):
|
|||||||
|
|
||||||
async def list_published_names(self) -> set[str]:
|
async def list_published_names(self) -> set[str]:
|
||||||
return {name for name, record in self._skills.items() if record.get("published")}
|
return {name for name, record in self._skills.items() if record.get("published")}
|
||||||
|
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
return {name for name, record in self._skills.items() if record.get("always_on")}
|
||||||
|
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
return set(self._favorites.get(user_id, set()))
|
||||||
|
|
||||||
|
# test helper — not part of the abstract API
|
||||||
|
def add_favorite(self, user_id: str, skill_name: str) -> None:
|
||||||
|
self._favorites.setdefault(user_id, set()).add(skill_name)
|
||||||
+32
-1
@@ -10,10 +10,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, String, Text
|
from sqlalchemy import Boolean, DateTime, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class SkillRow(Base):
|
class SkillRow(Base):
|
||||||
@@ -33,6 +34,10 @@ class SkillRow(Base):
|
|||||||
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
# the on-disk SKILL.md) so it can be set for built-in skills too, and so the
|
||||||
# shared source file is never rewritten.
|
# shared source file is never rewritten.
|
||||||
name_zh: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None)
|
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
|
# Publish square (发布广场) this skill is filed under once published. Pure
|
||||||
# organizational category, no access control. Empty string = unassigned →
|
# organizational category, no access control. Empty string = unassigned →
|
||||||
# the listing layer resolves it to the default square. One square per skill.
|
# the listing layer resolves it to the default square. One square per skill.
|
||||||
@@ -41,6 +46,32 @@ class SkillRow(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=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):
|
class SkillDisplayAdapterRow(Base):
|
||||||
__tablename__ = "skill_display_adapters"
|
__tablename__ = "skill_display_adapters"
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -9,7 +9,7 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.skills.base import SkillStore
|
from deerflow.persistence.skills.base import SkillStore
|
||||||
from deerflow.persistence.skills.model import SkillRow
|
from deerflow.persistence.skills.model import SkillFavoriteRow, SkillRow
|
||||||
|
|
||||||
|
|
||||||
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
|
def _row_to_dict(row: SkillRow) -> dict[str, Any]:
|
||||||
@@ -65,6 +65,7 @@ class SkillRepository(SkillStore):
|
|||||||
published=bool(data.get("published", False)),
|
published=bool(data.get("published", False)),
|
||||||
detail=data.get("detail"),
|
detail=data.get("detail"),
|
||||||
name_zh=data.get("name_zh"),
|
name_zh=data.get("name_zh"),
|
||||||
|
always_on=bool(data.get("always_on", False)),
|
||||||
square_id=str(data.get("square_id") or ""),
|
square_id=str(data.get("square_id") or ""),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -110,6 +111,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
row.name_zh = data["name_zh"]
|
row.name_zh = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
row.always_on = bool(data["always_on"])
|
||||||
if "square_id" in data:
|
if "square_id" in data:
|
||||||
row.square_id = str(data["square_id"] or "")
|
row.square_id = str(data["square_id"] or "")
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -130,6 +133,8 @@ class SkillRepository(SkillStore):
|
|||||||
row.detail = data["detail"]
|
row.detail = data["detail"]
|
||||||
if "name_zh" in data:
|
if "name_zh" in data:
|
||||||
row.name_zh = data["name_zh"]
|
row.name_zh = data["name_zh"]
|
||||||
|
if "always_on" in data:
|
||||||
|
row.always_on = bool(data["always_on"])
|
||||||
if "square_id" in data:
|
if "square_id" in data:
|
||||||
row.square_id = str(data["square_id"] or "")
|
row.square_id = str(data["square_id"] or "")
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
@@ -171,3 +176,15 @@ class SkillRepository(SkillStore):
|
|||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return {row[0] for row in result.all()}
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
async def list_always_on_names(self) -> set[str]:
|
||||||
|
stmt = select(SkillRow.name).where(SkillRow.always_on.is_(True))
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
async def list_favorite_skill_names(self, user_id: str) -> set[str]:
|
||||||
|
stmt = select(SkillFavoriteRow.skill_name).where(SkillFavoriteRow.user_id == user_id)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
+34
-15
@@ -1,9 +1,9 @@
|
|||||||
"""岗位 (position) → resource subscription **sync engine**.
|
"""岗位 (position) → resource subscription **sync engine**.
|
||||||
|
|
||||||
A user's position decides which agents / scheduled tasks / custom prompts they
|
A user's position decides which agents / skills / scheduled tasks they are
|
||||||
are subscribed to, and which skills / recommended questions they can see. This
|
subscribed to, and which recommended questions they can see. This service turns
|
||||||
service turns a position's *tag bindings* into concrete, materialized rows in
|
a position's *tag bindings* into concrete, materialized rows in the existing
|
||||||
the existing "我的" tables (idempotent, re-runnable).
|
"我的" tables (idempotent, re-runnable).
|
||||||
|
|
||||||
Resolution rule (per resource type):
|
Resolution rule (per resource type):
|
||||||
|
|
||||||
@@ -14,15 +14,19 @@ Resolution rule (per resource type):
|
|||||||
* For a resource type **without** bound tags → the *default set*:
|
* For a resource type **without** bound tags → the *default set*:
|
||||||
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
- ``agent`` → all built-in agents (``user_id IS NULL``)
|
||||||
- ``scheduled_task`` → empty (no built-in tasks)
|
- ``scheduled_task`` → empty (no built-in tasks)
|
||||||
- ``skill`` / ``recommended_question`` → *unrestricted* (handled at query
|
- ``skill`` → empty (the 广场 still shows every visible skill;
|
||||||
time, never materialized — see :meth:`resolve_visible`)
|
a position only *adds* its tagged skills to the member's "我的技能")
|
||||||
|
- ``recommended_question`` → *unrestricted* (handled at query time, never
|
||||||
|
materialized — see :meth:`resolve_visible`)
|
||||||
|
|
||||||
Materialization (``sync_user``) only ever touches rows it created itself
|
Materialization (``sync_user``) only ever touches rows it created itself
|
||||||
(``origin='position'``), so a user's own favorites / subscriptions are never
|
(``origin='position'``), so a user's own favorites / subscriptions are never
|
||||||
clobbered. The skill + recommended-question visibility filters are computed live
|
clobbered. ``skill`` is materialized into ``skill_favorites`` exactly like
|
||||||
by :meth:`resolve_visible` and are *not* materialized. (The admin-configured
|
``agent`` → ``agent_favorites``: the tagged skills are *added* to the user's
|
||||||
prompts shown to users ARE the recommended questions — there is no separate
|
"我的技能" while the browse-all 广场 stays complete (it is **not** restricted).
|
||||||
prompt-template store.)
|
Only recommended-question visibility is computed live by :meth:`resolve_visible`
|
||||||
|
and is *not* materialized. (The admin-configured prompts shown to users ARE the
|
||||||
|
recommended questions — there is no separate prompt-template store.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -37,14 +41,15 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
|
||||||
from deerflow.persistence.positions.base import PositionStore
|
from deerflow.persistence.positions.base import PositionStore
|
||||||
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskSubscriptionRow
|
||||||
|
from deerflow.persistence.skills.model import SkillFavoriteRow
|
||||||
from deerflow.persistence.tags.base import TagStore
|
from deerflow.persistence.tags.base import TagStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Materialized into "我的" tables by sync_user.
|
# Materialized into "我的" tables by sync_user.
|
||||||
_MATERIALIZED_TYPES = ("agent", "scheduled_task")
|
_MATERIALIZED_TYPES = ("agent", "skill", "scheduled_task")
|
||||||
# Resolved live at query time (visibility filters), never materialized.
|
# Resolved live at query time (visibility filters), never materialized.
|
||||||
_VISIBILITY_TYPES = ("skill", "recommended_question")
|
_VISIBILITY_TYPES = ("recommended_question",)
|
||||||
|
|
||||||
|
|
||||||
class PositionSyncService:
|
class PositionSyncService:
|
||||||
@@ -64,9 +69,10 @@ class PositionSyncService:
|
|||||||
async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None:
|
async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None:
|
||||||
"""Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted.
|
"""Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted.
|
||||||
|
|
||||||
Used by the skills list + recommended-questions filters. ``None`` means
|
Used by the recommended-questions filter (the only remaining query-time
|
||||||
"no position restriction → show everything"; a set means "restrict to
|
visibility type; skills are now *materialized*, not filtered). ``None``
|
||||||
exactly these ids".
|
means "no position restriction → show everything"; a set means "restrict
|
||||||
|
to exactly these ids".
|
||||||
"""
|
"""
|
||||||
pid = await self._effective_position_id(user_id)
|
pid = await self._effective_position_id(user_id)
|
||||||
if not pid:
|
if not pid:
|
||||||
@@ -101,6 +107,7 @@ class PositionSyncService:
|
|||||||
pid = await self._effective_position_id(user_id)
|
pid = await self._effective_position_id(user_id)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
await self._sync_agents(session, user_id, pid)
|
await self._sync_agents(session, user_id, pid)
|
||||||
|
await self._sync_skills(session, user_id, pid)
|
||||||
await self._sync_tasks(session, user_id, pid)
|
await self._sync_tasks(session, user_id, pid)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -116,6 +123,18 @@ class PositionSyncService:
|
|||||||
if agent_id not in target:
|
if agent_id not in target:
|
||||||
await session.delete(row)
|
await session.delete(row)
|
||||||
|
|
||||||
|
async def _sync_skills(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
||||||
|
target = await self._resolve_materialized(user_id, "skill", session)
|
||||||
|
rows = list((await session.execute(select(SkillFavoriteRow).where(SkillFavoriteRow.user_id == user_id))).scalars())
|
||||||
|
owned_any = {r.skill_name for r in rows}
|
||||||
|
position_rows = {r.skill_name: r for r in rows if r.origin == "position"}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for skill_name in target - owned_any:
|
||||||
|
session.add(SkillFavoriteRow(id=uuid.uuid4().hex, user_id=user_id, skill_name=skill_name, origin="position", position_id=position_id, created_at=now))
|
||||||
|
for skill_name, row in position_rows.items():
|
||||||
|
if skill_name not in target:
|
||||||
|
await session.delete(row)
|
||||||
|
|
||||||
async def _sync_tasks(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
async def _sync_tasks(self, session: AsyncSession, user_id: str, position_id: str | None) -> None:
|
||||||
target = await self._resolve_materialized(user_id, "scheduled_task", session)
|
target = await self._resolve_materialized(user_id, "scheduled_task", session)
|
||||||
rows = list((await session.execute(select(ScheduledTaskSubscriptionRow).where(ScheduledTaskSubscriptionRow.user_id == user_id))).scalars())
|
rows = list((await session.execute(select(ScheduledTaskSubscriptionRow).where(ScheduledTaskSubscriptionRow.user_id == user_id))).scalars())
|
||||||
|
|||||||
-1
@@ -247,7 +247,6 @@ class SkillStorage(ABC):
|
|||||||
extensions_config = ExtensionsConfig.from_file()
|
extensions_config = ExtensionsConfig.from_file()
|
||||||
for skill in skills:
|
for skill in skills:
|
||||||
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
|
skill.enabled = extensions_config.is_skill_enabled(skill.name, skill.category)
|
||||||
skill.always_on = extensions_config.is_skill_always_on(skill.name)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to load extensions config: %s", e)
|
logger.warning("Failed to load extensions config: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ CALL _add_column_if_missing('skills', 'detail', 'TEXT NULL DEFAULT NULL');
|
|||||||
-- square_id: 技能所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
-- square_id: 技能所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||||
-- ORM: SkillRow.square_id = String(64), nullable=False, default=''。
|
-- ORM: SkillRow.square_id = String(64), nullable=False, default=''。
|
||||||
CALL _add_column_if_missing('skills', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
CALL _add_column_if_missing('skills', 'square_id', "VARCHAR(64) NOT NULL DEFAULT ''");
|
||||||
|
-- always_on: 常驻(免压缩) —— 开启技能压缩后仍全文注入系统提示词,对应 migration 20260616_03。
|
||||||
|
-- ORM: SkillRow.always_on = Boolean, nullable=False, default=False。
|
||||||
|
CALL _add_column_if_missing('skills', 'always_on', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
-- ---- agents ------------------------------------------------------------------
|
-- ---- agents ------------------------------------------------------------------
|
||||||
-- square_id: 智能体所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
-- square_id: 智能体所属的发布广场(发布广场功能),对应 migration 20260611_01。
|
||||||
@@ -133,7 +136,7 @@ DEALLOCATE PREPARE stmt;
|
|||||||
SELECT table_name, column_name
|
SELECT table_name, column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = DATABASE()
|
WHERE table_schema = DATABASE()
|
||||||
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id'))
|
AND ((table_name = 'skills' AND column_name IN ('name_zh', 'detail', 'square_id', 'always_on'))
|
||||||
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
OR (table_name = 'agents' AND column_name IN ('square_id'))
|
||||||
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
|
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id'))
|
||||||
ORDER BY table_name, column_name;
|
ORDER BY table_name, column_name;
|
||||||
Reference in new issue
Block a user