diff --git a/README.md b/README.md
index a1a23c2..a686e7d 100644
--- a/README.md
+++ b/README.md
@@ -115,6 +115,8 @@ Self-host with Docker — full runbook (build gate, DB backup, image roll-out, r
| `DATA_DIR` | `./data` | SQLite location |
| `STORAGE_DIR` | `./data/skills_files` | Skill files on disk |
| `ASH_REQUIRE_REVIEW` | `1` | `0` publishes agent pushes directly (high-risk security hits are still held for review) |
+| `ASH_EMBED_URL` | — | Optional semantic layer: an Ollama URL (e.g. `http://127.0.0.1:11434`) or an OpenAI-compatible `/v1` endpoint. Finds duplicates and matches tasks across languages |
+| `ASH_EMBED_MODEL` | `bge-m3` | Embedding model (use a multilingual one); `ASH_EMBED_KEY` for OpenAI-compatible APIs |
| `ASH_REGISTRATION` | `open` | `closed` disables self-service sign-up (create accounts with `npm run user:create`) |
| `ASH_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` — which hops may set `X-Forwarded-*` (client IP for login rate limiting, `https` for `Secure` cookies) |
| `ASH_SEED` | `1` | `0` skips the example skills given to each new account |
diff --git a/client/scripts/format.test.mjs b/client/scripts/format.test.mjs
index a78ab7b..cfcba86 100644
--- a/client/scripts/format.test.mjs
+++ b/client/scripts/format.test.mjs
@@ -4,8 +4,8 @@ import { formatShortDate, parseTimestamp, relativeTime } from '../src/utils/date
import { folderLabels, targetFolderForView } from '../src/utils/systemFolders.js';
test('system views select inbox as the creation target; real folders are retained', () => {
- assert.deepEqual(Object.keys(folderLabels).sort(), ['all', 'inbox', 'pending', 'recent', 'starred', 'trash']);
- for (const view of ['all', 'starred', 'trash', 'recent', 'pending']) {
+ assert.deepEqual(Object.keys(folderLabels).sort(), ['all', 'attention', 'inbox', 'pending', 'recent', 'starred', 'trash']);
+ for (const view of ['all', 'starred', 'trash', 'recent', 'pending', 'attention']) {
assert.equal(targetFolderForView(view), 'inbox');
}
assert.equal(targetFolderForView('inbox'), 'inbox');
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 73a5abc..478ca8e 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -799,6 +799,7 @@ export default function App() {
onSelectTag={handleSelectTag}
onCopySkill={handleCopySkill}
onChanged={refreshAll}
+ onOpenSkill={handleRevealSkill}
apiRef={detailApiRef}
/>
diff --git a/client/src/components/FolderTree.jsx b/client/src/components/FolderTree.jsx
index 089fed1..cc9bb18 100644
--- a/client/src/components/FolderTree.jsx
+++ b/client/src/components/FolderTree.jsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
+ Activity,
Archive,
ClipboardPaste,
ChevronDown,
@@ -31,8 +32,10 @@ const navItems = [
{ id: 'all', label: folderLabels.all, icon: Archive },
{ id: 'trash', label: folderLabels.trash, icon: Trash2, droppable: true },
];
-// 待审核只在有内容(或正在查看)时出现:关闭审核的部署里不占位
+// 待审核、需关注只在有内容(或正在查看)时出现:没有要处理的事就不占位
const pendingNavItem = { id: 'pending', label: folderLabels.pending, icon: ShieldCheck };
+// 需关注:由使用数据推出的信号(常失败 / 从未使用 / 长期未用 / 疑似重复),整理技能库从这里开始
+const attentionNavItem = { id: 'attention', label: folderLabels.attention, icon: Activity };
function findNode(nodes, path) {
for (const node of nodes) {
@@ -74,7 +77,11 @@ export default function FolderTree({
const [menuOpen, setMenuOpen] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
const [dropTarget, setDropTarget] = useState(null);
- const systemItems = (stats?.pending > 0 || currentFolder === 'pending') ? [pendingNavItem, ...navItems] : navItems;
+ const systemItems = [
+ ...(stats?.pending > 0 || currentFolder === 'pending' ? [pendingNavItem] : []),
+ ...(stats?.attention > 0 || currentFolder === 'attention' ? [attentionNavItem] : []),
+ ...navItems,
+ ];
const treeRef = useRef(null);
useEffect(() => {
diff --git a/client/src/components/RelationsPanel.jsx b/client/src/components/RelationsPanel.jsx
new file mode 100644
index 0000000..61e52af
--- /dev/null
+++ b/client/src/components/RelationsPanel.jsx
@@ -0,0 +1,121 @@
+import { AlertTriangle, GitBranch, Info, Link2 } from 'lucide-react';
+import './Usage.css';
+
+const REF_STATUS = {
+ ok: null,
+ missing: { label: '库中没有', className: 'chip-danger' },
+ pending: { label: '待审核', className: 'chip-pending' },
+ foreign: { label: '其他账号', className: 'chip-muted' },
+ 'pinned-old': { label: '固定在历史版本', className: 'chip-muted' },
+ 'pin-missing': { label: '固定的版本不存在', className: 'chip-danger' },
+};
+
+function SkillLink({ skill, onOpenSkill }) {
+ if (!skill?.id) return {skill?.slug};
+ return (
+
+ );
+}
+
+// 技能检查结果:审核条与详情页共用
+export function LintList({ items }) {
+ if (!items?.length) return null;
+ return (
+
+ {items.map((item, i) => (
+ -
+ {item.level === 'warn' ? : }
+ {item.msg}
+
+ ))}
+
+ );
+}
+
+// 与其他技能相同的命令段
+export function SharedSteps({ items, onOpenSkill }) {
+ if (!items?.length) return null;
+ return (
+
+ {items.map((group, i) => (
+ -
+
+ 与 {group.skills.map((s, j) => (
+ {j > 0 && '、'}{s.meta && 元技能}
+ ))} 有 {group.commands.length} {group.kind === 'steps' ? '个相同的步骤' : '条相同的命令'}
+
+ {group.kind === 'steps'
+ ? {group.commands.map((step, j) => - {step}
)}
+ : {group.commands.join('\n')}}
+
+ ))}
+
+ );
+}
+
+// 详情页的引用关系:引用了谁、被谁引用、与谁有相同的步骤,以及技能检查(双语描述、引用写法、元技能契约)
+export default function RelationsPanel({ detail, onOpenSkill }) {
+ const refs = detail?.refs;
+ if (!refs) return null;
+ const lint = detail.status === 'pending' ? [] : (detail.lint || []);
+ const shared = detail.shared_steps || [];
+ const hasContent = refs.meta || refs.declared.length || refs.undeclared.length || refs.dependents.length || shared.length || lint.length;
+ if (!hasContent) return null;
+
+ return (
+
+
+
+ 引用关系
+ {refs.meta && 元技能}
+
+ 引用 {refs.declared.length} 个 · 被 {refs.dependents.length} 个技能引用
+
+
+ {refs.declared.length > 0 && (
+
+
引用
+
+ {refs.declared.map((ref) => (
+ -
+
+ {ref.pin && @{ref.pin}}
+ {REF_STATUS[ref.status] && {REF_STATUS[ref.status].label}}
+
+ ))}
+
+
+ )}
+ {refs.undeclared.length > 0 && (
+
+ 正文还引用了
+ {refs.undeclared.join('、')}(没写进 depends_on)
+
+ )}
+ {refs.dependents.length > 0 && (
+
+
被引用
+
+ {refs.dependents.map((s) => )}
+
+
+ )}
+ {shared.length > 0 && (
+ i.code === 'copies-meta' || i.code === 'extract-candidate')}>
+ 与其他技能相同的步骤({shared.length})
+
+
+ )}
+ {lint.length > 0 && (
+ i.level === 'warn')}>
+ 技能检查({lint.length})
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Review.css b/client/src/components/Review.css
index 9f3f885..9ed1852 100644
--- a/client/src/components/Review.css
+++ b/client/src/components/Review.css
@@ -14,6 +14,8 @@
.chip-pending { color: var(--accent); border-color: var(--accent-border); background: var(--accent-muted); }
.chip-danger { color: var(--danger-text); border-color: var(--danger-border); background: var(--danger-muted); }
+.chip-warning { color: var(--text-body); border-color: color-mix(in srgb, var(--warning) 45%, transparent); background: color-mix(in srgb, var(--warning) 12%, transparent); }
+.chip-muted { color: var(--text-muted); border-color: var(--border-muted); background: transparent; }
.diff-view { display: grid; gap: .6rem; min-width: 0; }
.diff-summary { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem; color: var(--text-muted); font-size: var(--fs-xs); }
diff --git a/client/src/components/ReviewPanel.jsx b/client/src/components/ReviewPanel.jsx
index 222bf79..c869f68 100644
--- a/client/src/components/ReviewPanel.jsx
+++ b/client/src/components/ReviewPanel.jsx
@@ -2,13 +2,15 @@ import { useState } from 'react';
import { AlertTriangle, Check, GitCompare, ShieldAlert, X } from 'lucide-react';
import { DialogShell } from './Modals';
import DiffView from './DiffView';
+import { FeedbackList, SimilarSkills } from './UsagePanel';
+import { LintList, SharedSteps } from './RelationsPanel';
import { relativeTime } from '../utils/date';
import { requestJson } from '../utils/requestJson';
import { showToast } from './toastBus';
import './Review.css';
// 详情页顶部的审核条:待审核的新技能 / Agent 提交的待审更新 / 安全扫描提醒
-export default function ReviewPanel({ detail, onChanged }) {
+export default function ReviewPanel({ detail, onChanged, onOpenSkill }) {
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [showDiff, setShowDiff] = useState(false);
@@ -39,6 +41,13 @@ export default function ReviewPanel({ detail, onChanged }) {
};
const source = update?.meta?.terminal_source || detail.terminal_source || 'Agent';
+ // 判断依据:新技能看库里有没有相近的(有没有必要单独成为一个技能),更新看当前版本收到的失败反馈(这次更新在修什么)
+ const similar = detail.similar || [];
+ const currentFailures = (detail.usage?.recent_feedback || []).filter((f) => f.outcome === 'fail' && f.current);
+ // 技能检查(双语描述、引用写法、元技能契约、抄了元技能的步骤):新技能查它本身,更新查待审内容
+ const checks = update ? (detail.pending_lint || []) : (detail.lint || []);
+ // 元技能的更新会影响所有引用它的技能
+ const dependents = detail.refs?.meta ? (detail.refs.dependents || []) : [];
return (
@@ -51,17 +60,54 @@ export default function ReviewPanel({ detail, onChanged }) {
+
+
相似的已有技能
+
+
+ {(detail.shared_steps || []).length > 0 && (
+
+
与已有技能相同的步骤
+
+
+ )}
+ {checks.length > 0 && (
+
+
技能检查
+
+
+ )}
)}
{update && (
-
{source} 提交了更新{update.submitted_at ? `(${relativeTime(update.submitted_at)})` : ''}。采纳前其他 Agent 仍拉取当前版本。
+
+ {source} 提交了更新{update.submitted_at ? `(${relativeTime(update.submitted_at)})` : ''}。采纳前其他 Agent 仍拉取当前版本。
+ {detail.pending_metadata_only && 只改了元数据}
+
+ {currentFailures.length > 0 && (
+
+
当前版本收到的失败反馈
+
+
+ )}
+ {dependents.length > 0 && (
+
+
这是元技能:采纳后会影响 {dependents.length} 个引用它的技能
+ ({ ...s, similarity: null }))} onOpenSkill={onOpenSkill} />
+
+ )}
+ {checks.length > 0 && (
+
+
技能检查(待审内容)
+
+
+ )}
)}
{[...warnings.map((w) => ({ ...w, scope: '当前版本' })), ...updateWarnings.map((w) => ({ ...w, scope: '待审更新' }))].map((w, i) => (
diff --git a/client/src/components/SkillDetail.jsx b/client/src/components/SkillDetail.jsx
index d62a41b..ecde356 100644
--- a/client/src/components/SkillDetail.jsx
+++ b/client/src/components/SkillDetail.jsx
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { VersionHistoryModal } from './Modals';
import ReviewPanel from './ReviewPanel';
+import UsagePanel from './UsagePanel';
+import RelationsPanel from './RelationsPanel';
import DetailToolbar from './detail/DetailToolbar';
import DocOutline from './detail/DocOutline';
import FileTreeSidebar from './detail/FileTreeSidebar';
@@ -19,7 +21,7 @@ function readSkillDraft(skillId) {
return null;
}
-export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag, onCopySkill, onChanged, apiRef }) {
+export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag, onCopySkill, onChanged, onOpenSkill, apiRef }) {
const [mode, setMode] = useState('preview');
const [content, setContent] = useState('');
// 列表接口不再回传 content,详情接口是唯一来源;savedContent 是「服务端上那一份」,用来判 dirty
@@ -311,7 +313,7 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
{copied ? '内容已复制' : ''}
{detailError &&
{detailError}
}
-
{ setReloadKey((k) => k + 1); await onChanged?.(); }} />
+ { setReloadKey((k) => k + 1); await onChanged?.(); }} />
{mode === 'preview' ? (
<>
@@ -368,6 +370,9 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
)}
+
+
+
{loadingDetail && !content ? (
正在载入技能详情…
) : content ? (
diff --git a/client/src/components/SkillList.jsx b/client/src/components/SkillList.jsx
index 8bc9207..7c9f567 100644
--- a/client/src/components/SkillList.jsx
+++ b/client/src/components/SkillList.jsx
@@ -5,6 +5,13 @@ import { showToast } from './toastBus';
import { folderLabels } from '../utils/systemFolders';
import './Review.css';
import { formatShortDate } from '../utils/date';
+
+const HEALTH_CHIP = {
+ failing: 'chip-danger', broken_ref: 'chip-danger', duplicate: 'chip-warning', copies_meta: 'chip-warning',
+ extractable: 'chip-warning', meta_contract: 'chip-warning', unused: 'chip-muted', stale: 'chip-muted',
+};
+// 平时只标需要尽快处理的;「需关注」视图里全部列出
+const ALWAYS_SHOWN = ['failing', 'broken_ref', 'duplicate'];
import {
ArrowDownAZ,
Check,
@@ -70,6 +77,7 @@ export default function SkillList({
onCollapse,
}) {
const isTrash = currentFolder === 'trash';
+ const isAttention = currentFolder === 'attention';
const listTitle = currentTag ? `标签:${currentTag}` : folderLabels[currentFolder] || currentFolder;
// 选到只剩 1 项时批量条不该消失:以「是否处于多选模式」为准,而不是 size > 1
const multiCount = multiSelectMode ? selectedIds.size : 0;
@@ -252,9 +260,9 @@ export default function SkillList({
) : skills.length === 0 ? (
{searchQuery ? : isTrash ? : }
-
{searchQuery ? '没有匹配的技能' : isTrash ? '废纸篓为空' : '这里还没有技能'}
-
{searchQuery ? '尝试缩短关键词或清除筛选。' : isTrash ? '移入废纸篓的技能会显示在这里。' : '创建技能后即可开始整理。'}
- {!isTrash && !searchQuery &&
}
+
{searchQuery ? '没有匹配的技能' : isTrash ? '废纸篓为空' : isAttention ? '没有需要关注的技能' : '这里还没有技能'}
+
{searchQuery ? '尝试缩短关键词或清除筛选。' : isTrash ? '移入废纸篓的技能会显示在这里。' : isAttention ? '常失败、从未使用、长期未用或疑似重复的技能会出现在这里。' : '创建技能后即可开始整理。'}
+ {!isTrash && !isAttention && !searchQuery &&
}
) : (
@@ -303,6 +311,9 @@ export default function SkillList({
{skill.status === 'pending' && 待审}
{skill.has_pending_update && 有更新}
{skill.warning_count > 0 && ⚠ {skill.warning_count}}
+ {(skill.health || []).filter((h) => isAttention || ALWAYS_SHOWN.includes(h.code)).map((h) => (
+ {h.label}
+ ))}
{skill.version && v{skill.version}}
{(skill.tags || []).slice(0, 2).map((tag) => (