diff --git a/backend/package/yuxi/agents/skills/buildin/__init__.py b/backend/package/yuxi/agents/skills/buildin/__init__.py
index eebb61fe..1dbc73a0 100644
--- a/backend/package/yuxi/agents/skills/buildin/__init__.py
+++ b/backend/package/yuxi/agents/skills/buildin/__init__.py
@@ -25,7 +25,7 @@ BUILTIN_SKILLS: list[BuiltinSkillSpec] = [
slug="reporter",
source_dir=_SKILLS_ROOT / "reporter",
description="生成 SQL 查询报表并生成可视化图表。",
- version="1.0.0",
+ version="2026.03.28",
tool_dependencies=[t["name"] for t in get_tool_info(get_mysql_tools())],
mcp_dependencies=("mcp-server-chart",),
),
diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py
index e43b42ec..8e382c56 100644
--- a/backend/package/yuxi/services/skill_service.py
+++ b/backend/package/yuxi/services/skill_service.py
@@ -838,7 +838,7 @@ async def update_builtin_skill(
item = await repo.get_by_slug(slug)
if not item:
raise ValueError(f"内置 skill '{slug}' 未安装")
- if not item.is_builtin:
+ if not item.is_builtin and not _is_builtin_managed(item, slug):
raise ValueError(f"技能 '{slug}' 不是内置 skill")
if item.content_hash != spec["content_hash"] and not force:
diff --git a/backend/test/test_skill_service.py b/backend/test/test_skill_service.py
index f5498e95..fa99160e 100644
--- a/backend/test/test_skill_service.py
+++ b/backend/test/test_skill_service.py
@@ -677,6 +677,110 @@ async def test_update_builtin_skill_needs_confirm_when_hash_mismatch(
assert exc_info.value.needs_confirm is True
+@pytest.mark.asyncio
+async def test_update_builtin_skill_accepts_legacy_managed_record(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
+
+ source_dir = tmp_path / "builtin" / "reporter"
+ source_dir.mkdir(parents=True, exist_ok=True)
+ (source_dir / "SKILL.md").write_text(
+ "---\nname: reporter\ndescription: builtin\n---\n# SQL Reporter\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(
+ svc,
+ "list_builtin_skill_specs",
+ lambda: [
+ {
+ "slug": "reporter",
+ "name": "reporter",
+ "description": "builtin",
+ "version": "1.0.1",
+ "tool_dependencies": ["mysql_query"],
+ "mcp_dependencies": ["charts"],
+ "skill_dependencies": [],
+ "content_hash": "hash-v2",
+ "source_dir": source_dir,
+ }
+ ],
+ )
+ monkeypatch.setattr(
+ svc,
+ "get_builtin_skill_specs",
+ lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)],
+ )
+
+ installed = Skill(
+ slug="reporter",
+ name="reporter",
+ description="old",
+ dir_path="skills/reporter",
+ created_by="system",
+ updated_by="system",
+ version=None,
+ content_hash=None,
+ )
+
+ captured: dict[str, object] = {}
+
+ class FakeRepo:
+ def __init__(self, _db):
+ pass
+
+ async def get_by_slug(self, slug: str):
+ return installed
+
+ async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None):
+ item.name = name
+ item.description = description
+ captured["metadata_updated_by"] = updated_by
+ return item
+
+ async def update_dependencies(
+ self,
+ item: Skill,
+ *,
+ tool_dependencies: list[str],
+ mcp_dependencies: list[str],
+ skill_dependencies: list[str],
+ updated_by: str | None,
+ ):
+ item.tool_dependencies = tool_dependencies
+ item.mcp_dependencies = mcp_dependencies
+ item.skill_dependencies = skill_dependencies
+ captured["deps_updated_by"] = updated_by
+ return item
+
+ async def update_builtin_install(
+ self,
+ item: Skill,
+ *,
+ version: str,
+ content_hash: str,
+ updated_by: str | None,
+ ):
+ item.version = version
+ item.content_hash = content_hash
+ item.is_builtin = True
+ item.updated_by = updated_by
+ captured["version"] = version
+ captured["content_hash"] = content_hash
+ captured["updated_by"] = updated_by
+ return item
+
+ monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
+
+ item = await svc.update_builtin_skill(None, "reporter", force=True, updated_by="root")
+
+ assert item.is_builtin is True
+ assert item.version == "1.0.1"
+ assert item.content_hash == "hash-v2"
+ assert captured["updated_by"] == "root"
+
+
@pytest.mark.asyncio
async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md
index 728d8bd9..80b2a64a 100644
--- a/docs/develop-guides/roadmap.md
+++ b/docs/develop-guides/roadmap.md
@@ -50,6 +50,7 @@
### 修复
+- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
- 调整沙盒 user-data 目录隔离策略:`workspace` 改为全局共享目录 `saves/user-data/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试
- 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试
- 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置
diff --git a/web/src/assets/css/extensions.less b/web/src/assets/css/extensions.less
index ec8aff92..e1b0052b 100644
--- a/web/src/assets/css/extensions.less
+++ b/web/src/assets/css/extensions.less
@@ -93,6 +93,9 @@
transition: all 0.2s;
border: 1px solid var(--gray-100);
background-color: var(--gray-0);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
&:hover {
border-color: @border-color;
@@ -136,7 +139,7 @@
.item-details {
display: flex;
- flex-direction: column;
+ // flex-direction: column;
gap: 4px;
}
diff --git a/web/src/components/SkillsManagerComponent.vue b/web/src/components/SkillsManagerComponent.vue
index 18ed0e02..90e8aaf2 100644
--- a/web/src/components/SkillsManagerComponent.vue
+++ b/web/src/components/SkillsManagerComponent.vue
@@ -18,76 +18,47 @@
-
+
-
内置 Skills
-
+ 已安装 Skills
+
-
-
-
{{ skill.slug }}
-
可更新
-
已安装
-
未安装
-
-
-
+
+
-
+
- 安装
-
-
更新
+
+ {{ skill.statusLabel }}
+
-
-
-
-
-
已安装 Skills
-
-
-
-
-
{{ skill.slug }}
+
+
+
+ {{ skill.sourceLabel }}
+
+
-
+
+
+
+
未安装 Skills
+
+
+
@@ -124,6 +137,13 @@
+
+ {{ currentSkillStatusLabel }}
+
{
- const installedSkills = (skills.value || []).filter((skill) => !skill.is_builtin)
- if (!searchQuery.value) return installedSkills
+const matchesSearch = (skill) => {
+ if (!searchQuery.value) return true
const q = searchQuery.value.toLowerCase()
- return installedSkills.filter(
- (s) => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q)
+ return skill.name.toLowerCase().includes(q) || skill.slug.toLowerCase().includes(q)
+}
+
+const installedSkillCards = computed(() => {
+ const builtinInstalledMap = new Map(
+ (builtinSkills.value || [])
+ .filter((skill) => skill.status !== 'not_installed')
+ .map((skill) => [
+ skill.slug,
+ {
+ ...skill,
+ sourceType: 'builtin',
+ sourceLabel: '内置',
+ statusLabel: skill.status === 'update_available' ? '更新可用' : '已安装',
+ statusTone: skill.status === 'update_available' ? 'warning' : 'default'
+ }
+ ])
)
+
+ const importedInstalled = (skills.value || [])
+ .filter((skill) => !builtinInstalledMap.has(skill.slug))
+ .map((skill) => ({
+ ...skill,
+ sourceType: 'imported',
+ sourceLabel: '导入',
+ statusLabel: '已上传',
+ statusTone: 'default'
+ }))
+
+ return [...builtinInstalledMap.values(), ...importedInstalled]
})
-const filteredBuiltinSkills = computed(() => {
- if (!searchQuery.value) return builtinSkills.value
- const q = searchQuery.value.toLowerCase()
- return builtinSkills.value.filter(
- (s) => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q)
- )
+const filteredInstalledSkills = computed(() => installedSkillCards.value.filter(matchesSearch))
+
+const filteredUninstalledBuiltinSkills = computed(() => {
+ return (builtinSkills.value || []).filter((skill) => skill.status === 'not_installed' && matchesSearch(skill))
})
const isInstalledSkill = computed(() => {
@@ -432,6 +476,22 @@ const isBuiltinInstalledSkill = computed(() => {
return !!(isInstalledSkill.value && (currentSkill.value?.is_builtin || currentSkill.value?.installed_record))
})
+const currentSkillStatusLabel = computed(() => {
+ const skill = currentSkill.value
+ if (!skill) return ''
+ if (skill.is_builtin_spec) {
+ if (skill.status === 'not_installed') return '未安装'
+ if (skill.status === 'update_available') return '更新可用'
+ return '已安装'
+ }
+ if (skill.is_builtin) return '已安装'
+ return '已上传'
+})
+
+const currentSkillStatusTone = computed(() => {
+ return currentSkill.value?.status === 'update_available' ? 'warning' : 'default'
+})
+
const canSave = computed(() => {
if (!selectedPath.value || selectedIsDir.value) return false
return fileContent.value !== originalFileContent.value
@@ -501,7 +561,9 @@ const fetchSkills = async () => {
}))
// 默认选中第一个技能并加载 SKILL.md
- const preferredList = builtinSkills.value.length ? builtinSkills.value : filteredSkills.value
+ const preferredList = filteredInstalledSkills.value.length
+ ? filteredInstalledSkills.value
+ : filteredUninstalledBuiltinSkills.value
if (!currentSkill.value && preferredList.length > 0) {
await selectSkill(preferredList[0])
} else if (currentSkill.value) {
@@ -816,53 +878,124 @@ defineExpose({
text-transform: uppercase;
}
-.list-item {
- .item-header {
- gap: 8px;
+.skill-list-item {
+
+ .item-main-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
}
- .builtin-badge {
- padding: 1px 6px;
+ .item-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ margin-bottom: 0;
+
+ .item-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .item-status {
+ flex-shrink: 0;
+ }
+
+ .status-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 24px;
+ padding: 0 9px;
border-radius: 999px;
- background: var(--color-primary-50);
- color: var(--color-primary-700);
+ background: var(--gray-100);
+ color: var(--gray-600);
font-size: 11px;
- line-height: 18px;
+ font-weight: 600;
+ line-height: 1;
+
+ &.warning {
+ background: var(--color-warning-50);
+ color: var(--color-warning-900);
+ }
}
.item-details {
+ display: flex;
align-items: center;
+ justify-content: space-between;
+ gap: 12px;
- .status-text {
- color: var(--gray-500);
- font-size: 12px;
- &.warning {
- color: #d97706;
- }
+ .item-tags {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
}
- .item-inline-actions {
- margin-left: auto;
+ .source-tag {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 20px;
+ padding: 0 8px;
+ border-radius: 999px;
+ background: var(--color-info-50);
+ color: var(--color-info-700);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ flex-shrink: 0;
+
+ &.builtin {
+ background: var(--color-primary-50);
+ color: var(--color-primary-700);
+ }
}
.item-badges {
display: flex;
gap: 4px;
+ flex-shrink: 0;
.dot-badge {
width: 6px;
height: 6px;
border-radius: 50%;
&.blue {
- background-color: #3b82f6;
+ background-color: var(--color-info-500);
}
&.green {
- background-color: #22c55e;
+ background-color: var(--color-success-500);
}
}
}
}
}
+.panel-status-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 30px;
+ padding: 0 12px;
+ border-radius: 999px;
+ background: var(--gray-100);
+ color: var(--gray-600);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1;
+
+ &.warning {
+ background: var(--color-warning-50);
+ color: var(--color-warning-900);
+ }
+}
+
.builtin-uninstalled-state {
padding: 24px;
h3 {
@@ -956,7 +1089,7 @@ defineExpose({
font-size: 12px;
color: var(--gray-500);
.save-hint {
- color: #f59e0b;
+ color: var(--color-warning-500);
font-size: 10px;
margin-left: 4px;
}
diff --git a/web/src/views/ExtensionsView.vue b/web/src/views/ExtensionsView.vue
index cc83c87c..48242d09 100644
--- a/web/src/views/ExtensionsView.vue
+++ b/web/src/views/ExtensionsView.vue
@@ -18,7 +18,7 @@
>
- 导入 ZIP
+ 上传 ZIP