Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions client/scripts/format.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ export default function App() {
onSelectTag={handleSelectTag}
onCopySkill={handleCopySkill}
onChanged={refreshAll}
onOpenSkill={handleRevealSkill}
apiRef={detailApiRef}
/>
</Suspense>
Expand Down
11 changes: 9 additions & 2 deletions client/src/components/FolderTree.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity,
Archive,
ClipboardPaste,
ChevronDown,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(() => {
Expand Down
121 changes: 121 additions & 0 deletions client/src/components/RelationsPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 <code>{skill?.slug}</code>;
return (
<button type="button" className="similar-link" onClick={() => onOpenSkill?.(skill.id)} title={skill.slug}>
<Link2 size={12} aria-hidden="true" />
<span className="similar-name">{skill.name || skill.slug}</span>
<code>{skill.slug}</code>
</button>
);
}

// 技能检查结果:审核条与详情页共用
export function LintList({ items }) {
if (!items?.length) return null;
return (
<ul className="lint-list">
{items.map((item, i) => (
<li key={`${item.code}-${i}`} className={item.level === 'warn' ? 'is-warn' : 'is-info'}>
{item.level === 'warn' ? <AlertTriangle size={12} aria-hidden="true" /> : <Info size={12} aria-hidden="true" />}
<span>{item.msg}</span>
</li>
))}
</ul>
);
}

// 与其他技能相同的命令段
export function SharedSteps({ items, onOpenSkill }) {
if (!items?.length) return null;
return (
<ul className="shared-steps">
{items.map((group, i) => (
<li key={i}>
<div className="shared-steps-with">
与 {group.skills.map((s, j) => (
<span key={s.id}>{j > 0 && '、'}<SkillLink skill={s} onOpenSkill={onOpenSkill} />{s.meta && <span className="chip chip-muted">元技能</span>}</span>
))} 有 {group.commands.length} {group.kind === 'steps' ? '个相同的步骤' : '条相同的命令'}
</div>
{group.kind === 'steps'
? <ol className="shared-steps-prose">{group.commands.map((step, j) => <li key={j}>{step}</li>)}</ol>
: <pre>{group.commands.join('\n')}</pre>}
</li>
))}
</ul>
);
}

// 详情页的引用关系:引用了谁、被谁引用、与谁有相同的步骤,以及技能检查(双语描述、引用写法、元技能契约)
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 (
<section className="usage-panel relations-panel" aria-label="引用关系">
<header>
<GitBranch size={14} aria-hidden="true" />
<strong>引用关系</strong>
{refs.meta && <span className="chip chip-muted" title="被别的技能在流程中引用的基础动作">元技能</span>}
<span className="usage-stats">
引用 {refs.declared.length} 个 · 被 {refs.dependents.length} 个技能引用
</span>
</header>
{refs.declared.length > 0 && (
<div className="relations-row">
<span className="relations-label">引用</span>
<ul className="similar-list">
{refs.declared.map((ref) => (
<li key={ref.raw}>
<SkillLink skill={ref} onOpenSkill={onOpenSkill} />
{ref.pin && <span className="chip chip-muted" title="固定的版本">@{ref.pin}</span>}
{REF_STATUS[ref.status] && <span className={`chip ${REF_STATUS[ref.status].className}`}>{REF_STATUS[ref.status].label}</span>}
</li>
))}
</ul>
</div>
)}
{refs.undeclared.length > 0 && (
<div className="relations-row">
<span className="relations-label">正文还引用了</span>
<span className="usage-empty">{refs.undeclared.join('、')}(没写进 depends_on)</span>
</div>
)}
{refs.dependents.length > 0 && (
<div className="relations-row">
<span className="relations-label">被引用</span>
<ul className="similar-list">
{refs.dependents.map((s) => <li key={s.id}><SkillLink skill={s} onOpenSkill={onOpenSkill} /></li>)}
</ul>
</div>
)}
{shared.length > 0 && (
<details className="usage-section" open={lint.some((i) => i.code === 'copies-meta' || i.code === 'extract-candidate')}>
<summary>与其他技能相同的步骤({shared.length})</summary>
<SharedSteps items={shared} onOpenSkill={onOpenSkill} />
</details>
)}
{lint.length > 0 && (
<details className="usage-section" open={lint.some((i) => i.level === 'warn')}>
<summary>技能检查({lint.length})</summary>
<LintList items={lint} />
</details>
)}
</section>
);
}
2 changes: 2 additions & 0 deletions client/src/components/Review.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
50 changes: 48 additions & 2 deletions client/src/components/ReviewPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<section className="review-panel" aria-label="审核">
Expand All @@ -51,17 +60,54 @@ export default function ReviewPanel({ detail, onChanged }) {
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => act('approve')}><Check size={14} />{busy === 'approve' ? '处理中…' : '通过'}</button>
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => act('reject')}><X size={14} />拒绝</button>
</div>
<div className="review-evidence">
<h4>相似的已有技能</h4>
<SimilarSkills items={similar} onOpenSkill={onOpenSkill} emptyText="按名称、描述比对,没有发现相近的技能(没有双语描述的技能跨语言可能比对不出)。" />
</div>
{(detail.shared_steps || []).length > 0 && (
<div className="review-evidence">
<h4>与已有技能相同的步骤</h4>
<SharedSteps items={detail.shared_steps} onOpenSkill={onOpenSkill} />
</div>
)}
{checks.length > 0 && (
<div className="review-evidence">
<h4>技能检查</h4>
<LintList items={checks} />
</div>
)}
</div>
)}
{update && (
<div className="review-banner is-pending">
<GitCompare size={15} aria-hidden="true" />
<p><strong>{source} 提交了更新</strong>{update.submitted_at ? `(${relativeTime(update.submitted_at)})` : ''}。采纳前其他 Agent 仍拉取当前版本。</p>
<p>
<strong>{source} 提交了更新</strong>{update.submitted_at ? `(${relativeTime(update.submitted_at)})` : ''}。采纳前其他 Agent 仍拉取当前版本。
{detail.pending_metadata_only && <span className="chip chip-muted" title="正文和附属文件都没变,只改了 frontmatter(例如补双语描述、改依赖声明)">只改了元数据</span>}
</p>
<div className="review-actions">
<button type="button" className="secondary-button" onClick={() => setShowDiff(true)}><GitCompare size={14} />查看差异</button>
<button type="button" className="primary-button" disabled={Boolean(busy)} onClick={() => act('approve')}><Check size={14} />{busy === 'approve' ? '处理中…' : '采纳'}</button>
<button type="button" className="secondary-button" disabled={Boolean(busy)} onClick={() => act('reject')}><X size={14} />拒绝</button>
</div>
{currentFailures.length > 0 && (
<div className="review-evidence">
<h4>当前版本收到的失败反馈</h4>
<FeedbackList items={currentFailures.slice(0, 5)} />
</div>
)}
{dependents.length > 0 && (
<div className="review-evidence">
<h4>这是元技能:采纳后会影响 {dependents.length} 个引用它的技能</h4>
<SimilarSkills items={dependents.map((s) => ({ ...s, similarity: null }))} onOpenSkill={onOpenSkill} />
</div>
)}
{checks.length > 0 && (
<div className="review-evidence">
<h4>技能检查(待审内容)</h4>
<LintList items={checks} />
</div>
)}
</div>
)}
{[...warnings.map((w) => ({ ...w, scope: '当前版本' })), ...updateWarnings.map((w) => ({ ...w, scope: '待审更新' }))].map((w, i) => (
Expand Down
9 changes: 7 additions & 2 deletions client/src/components/SkillDetail.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -311,7 +313,7 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
<div className={`detail-content${isWideFileView ? ' is-wide-file' : ''}`}>
<div className="sr-only" aria-live="polite">{copied ? '内容已复制' : ''}</div>
{detailError && <div className="inline-error" role="alert">{detailError}</div>}
<ReviewPanel detail={reviewDetail} onChanged={async () => { setReloadKey((k) => k + 1); await onChanged?.(); }} />
<ReviewPanel detail={reviewDetail} onOpenSkill={onOpenSkill} onChanged={async () => { setReloadKey((k) => k + 1); await onChanged?.(); }} />

{mode === 'preview' ? (
<>
Expand Down Expand Up @@ -368,6 +370,9 @@ export default function SkillDetail({ skill, onSave, onSelectFolder, onSelectTag
</section>
)}

<UsagePanel detail={reviewDetail} onOpenSkill={onOpenSkill} />
<RelationsPanel detail={reviewDetail} onOpenSkill={onOpenSkill} />

{loadingDetail && !content ? (
<div className="content-skeleton" role="status"><span>正在载入技能详情…</span><i /><i /><i /><i /></div>
) : content ? (
Expand Down
Loading
Loading