Compare commits

..

16 Commits

Author SHA1 Message Date
codex-bot 0d8aacb9a2 Rebuild guidance wiki as a unified SaaS workspace 2026-10-29 20:26:24 +08:00
Zayden-Jung f490543853 Merge pull request 'Keep image retention policy checks on main only' (#18) from codex/retention-main-only into main
Wiki image retention checks / retention-policy (push) Has been cancelled
Reviewed-on: #18
2026-10-29 11:22:47 +00:00
codex-bot b94c1e43f1 Keep image retention checks on main updates only 2026-10-29 19:02:45 +08:00
Zayden-Jung 9a603cf87e Merge pull request 'Retain wiki rollback images and expire unused CI images' (#17) from codex/wiki-image-retention into main
Wiki image retention checks / retention-policy (push) Has been cancelled
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 40m26s
Reviewed-on: #17
2026-10-29 10:48:15 +00:00
codex-bot dbc3ad8c69 Retain verified wiki releases and expire unused CI images
Wiki image retention checks / retention-policy (pull_request) Successful in 52s
2026-10-29 18:43:57 +08:00
Zayden-Jung 1a7b8f5235 Merge pull request 'Record guidance design research and full acceptance evidence' (#10) from codex/guidance-research into main
Reviewed-on: #10
2026-10-29 10:34:07 +00:00
codex-bot d1020850a7 Record confirmed SMTP delivery and current main CI evidence 2026-10-29 18:27:21 +08:00
Zayden-Jung 254ac628be Merge pull request 'Fix public Agent discovery for independent guidance documents' (#12) from codex/guidance-agent-entry into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Has been cancelled
Reviewed-on: #12
2026-10-29 10:19:43 +00:00
Zayden-Jung be23fe7030 Merge pull request 'Isolate smoke authentication configuration from production' (#16) from codex/smoke-auth-fixture into main
waterfall-wiki CI and deployment / verify-and-deploy (push) Successful in 19m48s
Reviewed-on: #16
2026-10-29 10:19:27 +00:00
codex-bot 6c498255e9 Isolate smoke authentication settings in the loopback child 2026-10-29 18:18:47 +08:00
codex-bot 1100b79062 Record remaining public SkillHub page templates 2026-10-29 17:57:56 +08:00
codex-bot 89f72512ae Record observed directory filtering and empty-result behavior 2026-10-29 17:49:52 +08:00
codex-bot 554c0d120f Document complete homepage reference observations and delivery boundaries 2026-10-29 17:37:53 +08:00
codex-bot 21983619f3 Analyze reference discussions, evaluation UI and identity-gated publishing 2026-10-29 17:22:25 +08:00
codex-bot 5410da8b6d Specify page flows, component responsibilities and reference design choices 2026-10-29 17:15:27 +08:00
codex-bot af1b2c34e8 Track product requirements and runtime acceptance evidence 2026-10-29 17:08:04 +08:00
35 changed files with 6379 additions and 206 deletions
+9 -15
View File
@@ -8,6 +8,7 @@ on:
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
@@ -22,19 +23,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate configuration
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
- name: Build and verify waterfall-wiki
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
- name: Verify retained character originals
run: docker run --rm --entrypoint node "waterfall-wiki-tests:${{ gitea.sha }}" --test tests/character-pack.test.mjs
- name: Build runtime
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
- name: Integration stack
- name: Build, verify, deploy and retain wiki images
env:
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
run: bash scripts/integration.sh
- name: Deploy waterfall-wiki
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy.sh "${{ gitea.sha }}"
RELEASE_SHA: ${{ gitea.sha }}
run: |
if [ "$(id -u)" -eq 0 ]; then
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
@@ -0,0 +1,19 @@
name: Wiki image retention checks
on:
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+2
View File
@@ -1,4 +1,5 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
@@ -6,6 +7,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
+3 -1
View File
@@ -16,7 +16,9 @@ async function availablePort() {
const port = process.env.SMOKE_PORT || await availablePort();
const release = "smoke-test";
const child = spawn(process.execPath, [".next/standalone/server.js"], {
env: { ...process.env, PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
// This loopback-only child checks retained resources without production credentials.
// Production keeps failing closed; the bypass requires both explicit test settings.
env: { ...process.env, AUTH_CONFIG_FILE: "", AUTH_DISABLED_FOR_TESTS: "true", PORT: port, HOSTNAME: "127.0.0.1", RELEASE_SHA: release },
stdio: "inherit",
});
try {
@@ -0,0 +1,495 @@
import Link from "next/link";
import { useRouter } from "next/router";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
ArrowRight,
ArrowUpRight,
Bot,
ChevronRight,
FileText,
Plus,
Search,
X,
} from "lucide-react";
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
import {
EmptyState,
KindBadge,
KindIcon,
Notice,
PageHeading,
kindNames,
Tabs,
} from "@/components/GuidanceUI";
import { guides, type Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
type Summary = {
id: string;
revision: number;
title: string;
summary: string;
kind: Guidance["kind"];
author?: string;
tags?: string[];
source: "community" | "example";
};
type Source = "all" | "published" | "examples";
const kinds = ["all", "guide", "context", "evidence"] as const;
const examples: Summary[] = guides.map((g) => ({ ...g, source: "example" }));
export default function DocumentDirectory({
communityOnly = false,
}: {
communityOnly?: boolean;
}) {
const router = useRouter();
const mine = communityOnly && router.query.scope === "mine";
return (
<GuidanceShell
title={mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档"}
>
<DirectoryContent communityOnly={communityOnly} mine={mine} />
</GuidanceShell>
);
}
function DirectoryContent({
communityOnly,
mine,
}: {
communityOnly: boolean;
mine: boolean;
}) {
const router = useRouter(),
account = useGuidanceAccount();
const [published, setPublished] = useState<Summary[]>([]),
[loading, setLoading] = useState(true),
[error, setError] = useState("");
const [q, setQ] = useState(""),
[kind, setKind] = useState("all"),
[source, setSource] = useState<Source>(communityOnly ? "published" : "all"),
[sort, setSort] = useState("default");
const controller = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setLoading(true);
setError("");
try {
const r = await fetch(
"/api/documents" +
(mine && account.user ? "?author=" + account.user.id : ""),
{ signal: abort.signal },
);
const b = await r.json();
if (!r.ok) throw Error(b.error || "目录暂时不可用");
if (!Array.isArray(b.documents)) throw Error("目录返回异常,请稍后重试");
setPublished(
b.documents.map((d: Omit<Summary, "source">) => ({
...d,
source: "community",
})),
);
} catch (e) {
if (!abort.signal.aborted)
setError(e instanceof Error ? e.message : "目录暂时不可用");
} finally {
if (!abort.signal.aborted) setLoading(false);
}
}, [mine, account.user?.id]);
useEffect(() => {
void refresh();
return () => controller.current?.abort();
}, [refresh]);
useEffect(() => {
if (!router.isReady) return;
setQ(typeof router.query.q === "string" ? router.query.q : "");
setKind(
typeof router.query.kind === "string" &&
kinds.some((k) => k === router.query.kind)
? router.query.kind
: "all",
);
setSource(
communityOnly
? "published"
: router.query.source === "published" ||
router.query.source === "examples"
? router.query.source
: "all",
);
setSort(router.query.sort === "title" ? "title" : "default");
}, [
router.isReady,
router.query.q,
router.query.kind,
router.query.source,
router.query.sort,
communityOnly,
]);
function remember(
next: { q?: string; kind?: string; source?: Source; sort?: string } = {},
) {
const values = { q, kind, source, sort, ...next },
query: Record<string, string> = {};
if (values.q) query.q = values.q;
if (values.kind !== "all") query.kind = values.kind;
if (!communityOnly && values.source !== "all") query.source = values.source;
if (values.sort !== "default") query.sort = values.sort;
if (mine) query.scope = "mine";
void router.push(
{ pathname: communityOnly ? "/documents" : "/", query },
undefined,
{ shallow: true, scroll: false },
);
}
function search(e: FormEvent) {
e.preventDefault();
remember();
}
function clear() {
setQ("");
setKind("all");
setSource(communityOnly ? "published" : "all");
setSort("default");
remember({
q: "",
kind: "all",
source: communityOnly ? "published" : "all",
sort: "default",
});
}
const all: Summary[] = communityOnly
? published
: [...published, ...examples];
const term = q.trim().toLocaleLowerCase();
const found = all.filter(
(d) =>
(!mine || d.author === account.user?.id) &&
(source === "all" ||
(source === "published" && d.source === "community") ||
(source === "examples" && d.source === "example")) &&
`${d.title} ${d.summary} ${d.id} ${d.tags?.join(" ") || ""}`
.toLocaleLowerCase()
.includes(term),
);
const documents = found.filter((d) => kind === "all" || d.kind === kind);
if (sort === "title")
documents.sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
const waiting = loading || (mine && account.loading),
filtered =
!!q ||
kind !== "all" ||
(!communityOnly && source !== "all") ||
sort !== "default";
const heading = mine ? "我的文档" : communityOnly ? "社区文档" : "探索文档";
return (
<>
<PageHeading
title={heading}
description={
mine
? "在这里管理你发布的文档,修改会创建新的固定版本。"
: communityOnly
? "来自真实作者的独立文档。找到有用的知识,从一篇开始阅读。"
: "为当前任务找到有用的指导,按需连接下一篇知识。"
}
eyebrow={
mine
? "YOUR KNOWLEDGE"
: communityOnly
? "COMMUNITY LIBRARY"
: "DISCOVER KNOWLEDGE"
}
>
<Link
className={s.secondaryButton}
href="/agent/index.md"
target="_blank"
>
<Bot size={15} />
Agent 入口
<ArrowUpRight size={13} />
</Link>
</PageHeading>
{!communityOnly && (
<>
<section className={s.introCard}>
<span className={s.introIcon}>
<Bot size={23} />
</span>
<div>
<h2>好用的知识,从不必整套加载。</h2>
<p>
每篇文档都有适用条件和固定版本。需要更多时,再沿着有用的链接继续阅读。
</p>
</div>
<div className={s.introDiagram} aria-hidden="true">
<span>
<FileText size={13} />
一篇文档
</span>
<ArrowRight size={15} />
<span>
<FileText size={13} />
下一页
</span>
</div>
<Link className={s.textLink} href="/protocol">
了解工作原理
<ArrowUpRight size={13} />
</Link>
</section>
<div className={s.topics}>
<span>试试这些任务</span>
{["代码审查", "规划", "验证"].map((t) => (
<button
type="button"
key={t}
onClick={() => {
setQ(t);
remember({ q: t });
}}
>
{t}
</button>
))}
</div>
</>
)}
{mine && !account.loading && !account.user ? (
<section className={s.panel}>
{account.error && (
<div className={s.panelBody}>
<Notice tone="error">
{account.error}{" "}
<button type="button" onClick={() => void account.refresh()}>
重新检查
</button>
</Notice>
</div>
)}
<EmptyState
title="登录后,管理你的知识"
description="使用已验证的邮箱账户发布文档、更新版本,并随时回到这里继续编辑。"
>
<Link
className={s.primaryButton}
href="/account?next=%2Fdocuments%3Fscope%3Dmine"
>
登录 / 创建账户
<ArrowRight size={15} />
</Link>
</EmptyState>
</section>
) : (
<>
<section className={s.panel} aria-label="文档目录">
<Tabs
tabs={kinds.map((k) => ({
id: k,
label: k === "all" ? "全部文档" : kindNames[k],
count: found.filter((d) => k === "all" || d.kind === k).length,
}))}
value={kind}
onChange={(value) => {
setKind(value);
remember({ kind: value });
}}
/>
<div className={s.catalogToolbar}>
<form className={s.catalogSearch} role="search" onSubmit={search}>
<Search size={16} />
<input
aria-label="搜索文档"
placeholder="搜索标题、摘要或文档 ID…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{q && (
<button
type="button"
aria-label="清空关键词"
onClick={() => {
setQ("");
remember({ q: "" });
}}
>
<X size={14} />
</button>
)}
<button type="submit" aria-label="提交搜索">
<ArrowRight size={15} />
</button>
</form>
<div className={s.catalogTools}>
{!communityOnly && (
<select
aria-label="文档来源"
value={source}
onChange={(e) => {
const value = e.target.value as Source;
setSource(value);
remember({ source: value });
}}
>
<option value="all">全部来源</option>
<option value="published">社区发布</option>
<option value="examples">协议样例</option>
</select>
)}
<select
aria-label="文档排序"
value={sort}
onChange={(e) => {
setSort(e.target.value);
remember({ sort: e.target.value });
}}
>
<option value="default">默认顺序</option>
<option value="title">按标题排序</option>
</select>
</div>
</div>
<div className={s.catalogStatus}>
<p role="status">
{waiting
? "正在加载文档…"
: error
? `当前显示 ${documents.length} 篇,社区目录加载失败`
: `找到 ${documents.length} 篇文档`}
</p>
{filtered ? (
<button type="button" aria-label="清除所有筛选" onClick={clear}>
清除所有筛选
</button>
) : (
<span>最多展示最近 100 篇社区文档</span>
)}
</div>
{error && (
<div className={s.panelBody}>
<Notice tone="error">
{error}{" "}
<button
type="button"
className={s.textLink}
onClick={() => void refresh()}
>
重新加载
</button>
</Notice>
</div>
)}
<div className={s.tableHeader} aria-hidden="true">
<span>文档</span>
<span>类型</span>
<span>版本</span>
<span className={s.tableSource}>来源</span>
<span />
</div>
{waiting && (!documents.length || communityOnly) ? (
<div aria-label="正在加载文档" aria-busy="true">
{[0, 1, 2].map((i) => (
<div className={s.skeletonRow} key={i}>
<div className={s.skeletonBox} />
<div className={s.skeletonLines}>
<div className={s.skeletonLine} />
<div className={s.skeletonLine} />
</div>
</div>
))}
</div>
) : (
documents.map((d) => (
<Link
href={`${d.source === "example" ? "/guide/" : "/documents/"}${d.id}?revision=${d.revision}`}
key={d.id}
className={s.documentRow}
>
<div className={s.documentPrimary}>
<span className={s.documentIcon}>
<KindIcon kind={d.kind} />
</span>
<div className={s.documentDetails}>
<h2 className={s.documentTitle}>{d.title}</h2>
<p className={s.documentSummary}>{d.summary}</p>
<p className={s.documentId} title={d.id}>
{d.id}
</p>
<div className={s.documentMobileMeta}>
<KindBadge kind={d.kind} />
<span>v{d.revision}</span>
<span>
{d.source === "example" ? "协议样例" : "社区发布"}
</span>
</div>
</div>
</div>
<span className={s.documentKind}>
<KindBadge kind={d.kind} />
</span>
<span className={s.documentVersion}>v{d.revision}</span>
<span className={s.documentSource}>
{d.source === "example" ? "协议样例" : "社区发布"}
</span>
<ChevronRight size={16} className={s.documentArrow} />
</Link>
))
)}
{!waiting && !documents.length && !error && (
<EmptyState
title={
filtered
? "没有匹配文档"
: mine
? "你的第一篇文档,从这里开始"
: "这里还没有社区文档"
}
description={
filtered
? "试试其他关键词或清除筛选,发现更多适合当前任务的知识。"
: mine
? "把你反复使用的方法写下来,让 Agent 在需要时找到它。"
: "你可以先阅读协议样例,也可以发布第一篇独立文档。"
}
>
{filtered ? (
<button
type="button"
className={s.secondaryButton}
onClick={clear}
>
重置筛选
</button>
) : (
<Link className={s.primaryButton} href="/write">
<Plus size={15} />
发布一篇文档
</Link>
)}
</EmptyState>
)}
</section>
<div className={s.catalogFootnote}>
<span>
{mine
? "只有你可以更新自己的文档,已发布的历史版本保持不变。"
: "社区文档与协议样例分别标识,采用前请确认适用条件与版本。"}
</span>
<Link href="/protocol">
了解读取规范
<ArrowUpRight size={12} />
</Link>
</div>
</>
)}
</>
);
}
@@ -0,0 +1,462 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ArrowUpRight,
BookOpen,
Braces,
CalendarDays,
CheckSquare,
ChevronRight,
FileText,
GitBranch,
Globe2,
ListChecks,
Link2,
Pencil,
Target,
Trash2,
} from "lucide-react";
import GuidanceShell from "@/components/GuidanceShell";
import {
CopyButton,
KindBadge,
Notice,
Tabs,
kindNames,
} from "@/components/GuidanceUI";
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
export type ReadingVersion = { revision: number; createdAt?: string };
export type ReadingLink = Guidance["links"][number] & { resolved: boolean };
export type ReaderProps = {
document: Guidance;
source: "example" | "community";
links: ReadingLink[];
versions: ReadingVersion[];
backlinks?: {
id: string;
revision?: number;
title: string;
source?: "example" | "community";
}[];
canEdit?: boolean;
withdrawn?: boolean;
};
const relationNames = {
guide: "可选指导",
context: "背景参考",
evidence: "事实依据",
};
function dateLabel(value?: string) {
return value
? new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "UTC",
}).format(new Date(value))
: "协议样例";
}
export default function GuidanceReader({
document: doc,
source,
links,
versions,
backlinks = [],
canEdit = false,
withdrawn = false,
}: ReaderProps) {
const router = useRouter(),
dialog = useRef<HTMLDialogElement>(null);
const [tab, setTab] = useState("overview"),
[busy, setBusy] = useState(false),
[error, setError] = useState(""),
[isWithdrawn, setWithdrawn] = useState(withdrawn);
useEffect(() => {
setWithdrawn(withdrawn);
setError("");
setTab("overview");
}, [doc.id, withdrawn]);
const api = `/api/${source === "example" ? "guides" : "documents"}/${doc.id}?revision=${doc.revision}&format=xml`;
const current = versions.find((v) => v.revision === doc.revision),
xml = guidanceXml(doc);
const route = source === "example" ? "/guide/" : "/documents/";
const sectionLinks = [
["applies", "适用条件"],
["content", "指导正文"],
...(doc.steps.length ? [["steps", "建议步骤"]] : []),
...(doc.checks.length ? [["checks", "完成检查"]] : []),
["related", "条件链接"],
];
async function withdraw() {
setBusy(true);
setError("");
try {
const response = await fetch("/api/documents/" + doc.id, {
method: "DELETE",
});
const body = await response.json();
if (!response.ok) throw Error(body.error || "撤下失败,请稍后重试");
setWithdrawn(true);
dialog.current?.close();
} catch (e) {
setError(e instanceof Error ? e.message : "撤下失败,请稍后重试");
} finally {
setBusy(false);
}
}
return (
<GuidanceShell title="文档详情">
<header className={s.readingHeader}>
<Link
className={s.backLink}
href={source === "example" ? "/" : "/documents"}
>
<ArrowLeft size={13} />
{source === "example" ? "返回探索文档" : "返回社区文档"}
</Link>
<div className={s.documentBadges}>
<KindBadge kind={doc.kind} />
<span className={s.neutralBadge}>
{source === "example" ? "协议样例" : "社区发布"}
</span>
<span className={s.neutralBadge}>v{doc.revision}</span>
{isWithdrawn && (
<span className={s.neutralBadge}>已撤下 · 历史版本</span>
)}
</div>
<h1>{doc.title}</h1>
<p className={s.readingSummary}>{doc.summary}</p>
<div className={s.readingMeta}>
<span>
<Globe2 size={13} />
公开文档
</span>
<span>
<GitBranch size={13} />
固定版本 {doc.revision}
</span>
{current?.createdAt && (
<span>
<CalendarDays size={13} />
{dateLabel(current.createdAt)}
</span>
)}
</div>
<div className={s.readingActions}>
<CopyButton
label="复制 Agent 读取地址"
value={() => window.location.origin + api}
/>
<a
href={api}
className={s.secondaryButton}
target="_blank"
rel="noreferrer"
>
<Braces size={15} />
读取规范 XML
<ArrowUpRight size={13} />
</a>
{canEdit && !isWithdrawn && (
<Link className={s.secondaryButton} href={"/write?id=" + doc.id}>
<Pencil size={14} />
编辑最新版本
</Link>
)}
</div>
</header>
{isWithdrawn && (
<Notice tone="warning">
这篇文档已从公开目录撤下。你正在阅读固定历史版本,已有引用仍可继续使用。
</Notice>
)}
<div className={s.readingLayout}>
<section className={s.panel}>
<Tabs
tabs={[
{ id: "overview", label: "文档概览" },
{ id: "xml", label: "XML 源文档" },
{ id: "versions", label: "版本记录", count: versions.length },
]}
value={tab}
onChange={setTab}
/>
{tab === "overview" ? (
<article className={s.readingArticle}>
<section
id="applies"
className={`${s.proseSection} ${s.appliesCard}`}
>
<h2>
<Target size={16} />
何时使用这篇文档
</h2>
<p>{doc.applies}</p>
</section>
<section id="content" className={s.proseSection}>
<h2>
<FileText size={16} />
指导正文
</h2>
<div className={s.proseBody}>{doc.body}</div>
</section>
{!!doc.steps.length && (
<section id="steps" className={s.proseSection}>
<h2>
<ListChecks size={17} />
建议步骤
</h2>
<ol className={s.stepList}>
{doc.steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</section>
)}
{!!doc.checks.length && (
<section id="checks" className={s.proseSection}>
<h2>
<CheckSquare size={16} />
完成检查
</h2>
<ul className={s.checkList}>
{doc.checks.map((check, index) => (
<li key={index}>{check}</li>
))}
</ul>
</section>
)}
<section id="related" className={s.proseSection}>
<h2>
<Link2 size={16} />
按需继续阅读
<span className={s.neutralBadge}>{links.length}</span>
</h2>
<p className={s.formDescription}>
只在条件适用、且当前任务需要时,打开下一篇文档。
</p>
{links.map((link, index) => {
const content = (
<>
<div>
<strong>{link.label}</strong>
<p>当{link.when}</p>
<small>
<span>{relationNames[link.relation]}</span>
<span>·</span>
<span>v{link.revision}</span>
{!link.resolved && (
<span>· 固定版本缺失,请跳过</span>
)}
</small>
</div>
{link.resolved && <ArrowUpRight size={15} />}
</>
);
return link.resolved ? (
<Link
className={s.relationCard}
key={index}
href={
(link.target.startsWith("user-")
? "/documents/"
: "/guide/") +
link.target +
"?revision=" +
link.revision
}
>
{content}
</Link>
) : (
<div
className={`${s.relationCard} ${s.unresolved}`}
key={index}
>
{content}
</div>
);
})}
{!links.length && (
<p className={s.proseBody}>
这篇文档可以独立使用,没有需要继续读取的关联文档。
</p>
)}
</section>
</article>
) : tab === "xml" ? (
<>
<div className={s.xmlToolbar}>
<span>{doc.id}.xml</span>
<CopyButton value={xml} label="复制 XML" />
</div>
<pre className={s.codeBlock}>{xml}</pre>
</>
) : (
<div className={s.versionsList}>
{versions.map((version) => (
<div className={s.versionRow} key={version.revision}>
<div>
<div className={s.buttonRow}>
<strong>版本 {version.revision}</strong>
{version.revision === doc.revision && (
<span className={s.neutralBadge}>当前阅读</span>
)}
</div>
<p>{dateLabel(version.createdAt)} · 固定版本</p>
</div>
<Link
className={s.textLink}
href={route + doc.id + "?revision=" + version.revision}
>
读取
<ChevronRight size={13} />
</Link>
</div>
))}
</div>
)}
</section>
<aside className={s.readingSidebar}>
<section className={s.sideCard}>
<h3>文档信息</h3>
<dl className={s.metadataList}>
<div>
<dt>类型</dt>
<dd>{kindNames[doc.kind]}</dd>
</div>
<div>
<dt>来源</dt>
<dd>
{source === "example"
? "织页协议样例"
: canEdit
? "你的文档"
: "社区作者"}
</dd>
</div>
<div>
<dt>当前版本</dt>
<dd>
<select
aria-label="选择阅读版本"
value={doc.revision}
onChange={(e) =>
void router.push(
route + doc.id + "?revision=" + e.target.value,
)
}
>
{versions.map((v) => (
<option key={v.revision} value={v.revision}>
v{v.revision}
</option>
))}
</select>
</dd>
</div>
<div>
<dt>可见范围</dt>
<dd>{isWithdrawn ? "仅固定历史版本" : "公开读取"}</dd>
</div>
</dl>
<code className={s.machineAddress}>{doc.id}</code>
{canEdit && !isWithdrawn && (
<div className={s.authorActions}>
<Link
className={s.secondaryButton}
href={"/write?id=" + doc.id}
>
<Pencil size={13} />
编辑文档
</Link>
<button
type="button"
className={s.quietButton}
onClick={() => dialog.current?.showModal()}
>
<Trash2 size={13} />
撤下文档
</button>
</div>
)}
</section>
<section className={`${s.sideCard} ${s.tocCard}`}>
<h3>本篇目录</h3>
<nav className={s.toc} aria-label="本篇目录">
{sectionLinks.map(([id, label]) => (
<a key={id} href={"#" + id} onClick={() => setTab("overview")}>
{label}
</a>
))}
</nav>
</section>
<section className={s.sideCard}>
<h3>
<BookOpen size={15} /> 采用这一篇
</h3>
<p>
把固定版本的读取地址交给 Agent。关联文档是候选帮助,无需一起加载。
</p>
<code className={s.machineAddress}>{api}</code>
<CopyButton
label="复制读取地址"
value={() => window.location.origin + api}
/>
</section>
{!!backlinks.length && (
<section className={s.sideCard}>
<h3>被这些文档引用</h3>
<div className={s.sideLinks}>
{backlinks.map((b) => (
<Link
key={b.id}
href={
(b.source === "community" ? "/documents/" : "/guide/") +
b.id +
(b.revision ? "?revision=" + b.revision : "")
}
>
{b.title}
<ArrowUpRight size={11} />
</Link>
))}
</div>
</section>
)}
</aside>
</div>
<dialog ref={dialog} className={s.modal} aria-labelledby="withdraw-title">
<div className={s.modalBody}>
<h2 id="withdraw-title">撤下这篇文档?</h2>
<p>
它将不再出现在目录中。已发布的固定历史版本会保留,已有引用仍然可以访问。
</p>
{error && <Notice tone="error">{error}</Notice>}
</div>
<div className={s.modalActions}>
<button
type="button"
disabled={busy}
className={s.secondaryButton}
onClick={() => dialog.current?.close()}
>
取消
</button>
<button
type="button"
disabled={busy}
className={s.dangerButton}
onClick={() => void withdraw()}
>
{busy ? "正在撤下…" : "确认撤下"}
</button>
</div>
</dialog>
</GuidanceShell>
);
}
@@ -1,4 +1,265 @@
import Link from 'next/link';
import type {ReactNode} from 'react';
import s from '@/styles/guidance.module.css';
export default function GuidanceShell({children}:{children:ReactNode}){return <div className={s.site}><header className={s.header}><Link className={s.brand} href="/">↗ 织页 <small>实验版</small></Link><nav><Link href="/">探索文档</Link><Link href="/documents">用户文档</Link><Link href="/write">发布文档</Link><Link href="/protocol">协议草案</Link></nav><Link className={s.account} href="/account">登录 / 注册 ↗</Link></header>{children}<footer className={s.footer}><span>织页 · Linked Guidance · 名称与协议仍在探索中</span><Link href="/protocol">让每一页都能成为起点 ↗</Link></footer></div>}
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import {
ArrowUpRight,
BookOpen,
Bot,
ChevronRight,
Compass,
Files,
Globe2,
LogIn,
Menu,
Plus,
SquarePen,
UserRound,
Waypoints,
X,
} from "lucide-react";
import s from "@/styles/guidance.module.css";
export type AccountUser = { id: string; email: string; verified_at: string };
type AccountState = {
user: AccountUser | null;
loading: boolean;
error: string;
refresh: () => Promise<void>;
};
const AccountContext = createContext<AccountState>({
user: null,
loading: true,
error: "",
refresh: async () => {},
});
export const useGuidanceAccount = () => useContext(AccountContext);
export default function GuidanceShell({
children,
title = "探索文档",
}: {
children: ReactNode;
title?: string;
}) {
const router = useRouter();
const dialog = useRef<HTMLDialogElement>(null);
const [user, setUser] = useState<AccountUser | null>(null),
[loading, setLoading] = useState(true),
[error, setError] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
setError("");
try {
const r = await fetch("/api/account/session");
const b = await r.json();
if (!r.ok) throw Error(b.error || "账户暂时不可用");
setUser(b.user || null);
} catch (e) {
setError(e instanceof Error ? e.message : "账户暂时不可用");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
dialog.current?.close();
}, [router.pathname, router.query.scope]);
const mine = router.query.scope === "mine";
const nav = [
{
href: "/",
label: "探索文档",
Icon: Compass,
active: router.pathname === "/" || router.pathname.startsWith("/guide/"),
},
{
href: "/documents",
label: "社区文档",
Icon: Files,
active: router.pathname.startsWith("/documents") && !mine,
},
{
href: "/documents?scope=mine",
label: "我的文档",
Icon: BookOpen,
active: router.pathname === "/documents" && mine,
},
{
href: "/write",
label: "发布文档",
Icon: SquarePen,
active: router.pathname === "/write",
},
];
function navigation(mobile = false) {
return (
<>
<Link
className={s.brand}
href="/"
onClick={() => dialog.current?.close()}
>
<span className={s.brandMark}>
<Waypoints size={22} strokeWidth={2.2} />
</span>
<span>
织页<small>KNOWLEDGE FOR AGENTS</small>
</span>
</Link>
<div className={s.workspaceLabel}>
<Globe2 size={16} />
<span>公共知识空间</span>
<span className={s.workspaceTag}>Public</span>
</div>
<p className={s.navLabel}>工作空间</p>
<nav
aria-label={mobile ? "移动端主导航" : "主导航"}
className={s.navigation}
>
{nav.map(({ href, label, Icon, active }) => (
<Link
href={href}
key={href}
aria-current={active ? "page" : undefined}
className={active ? s.navActive : ""}
onClick={() => dialog.current?.close()}
>
<Icon size={18} />
<span>{label}</span>
</Link>
))}
</nav>
<p className={s.navLabel}>资源</p>
<nav
aria-label={mobile ? "移动端资源" : "资源导航"}
className={s.navigation}
>
<Link
href="/protocol"
aria-current={router.pathname === "/protocol" ? "page" : undefined}
className={router.pathname === "/protocol" ? s.navActive : ""}
onClick={() => dialog.current?.close()}
>
<BookOpen size={18} />
<span>文档协议</span>
</Link>
<a href="/agent/index.md" target="_blank" rel="noreferrer">
<Bot size={18} />
<span>Agent 读取入口</span>
<ArrowUpRight size={14} className={s.navExternal} />
</a>
</nav>
<div className={s.sidebarBottom}>
<div className={s.sidebarHelp}>
<span className={s.smallLabel}>从一篇文档开始</span>
<p>选择适用的指导,需要更多背景时,再沿链接继续阅读。</p>
<Link href="/protocol">
了解使用方法 <ArrowUpRight size={13} />
</Link>
</div>
<Link href="/account" className={s.userArea}>
<span className={s.avatar}>
{user ? (
user.email.slice(0, 1).toUpperCase()
) : (
<UserRound size={18} />
)}
</span>
<span className={s.userInfo}>
<strong>
{user ? user.email : loading ? "正在检查账户…" : "登录你的账户"}
</strong>
<small>
{user ? "管理与发布你的文档" : "邮箱登录 · 开始发布"}
</small>
</span>
<ChevronRight size={15} />
</Link>
</div>
</>
);
}
return (
<AccountContext.Provider value={{ user, loading, error, refresh }}>
<div className={s.site}>
<Head>
<title>{`${title} · 织页`}</title>
<meta
name="description"
content="为 Agent 编写、发现和连接可独立使用的指导文档。"
/>
</Head>
<a className={s.skipLink} href="#workspace-content">
跳到正文
</a>
<aside className={s.sidebar}>{navigation()}</aside>
<dialog ref={dialog} className={s.mobileDialog} aria-label="工作区导航">
<button
type="button"
className={s.mobileClose}
aria-label="关闭导航"
onClick={() => dialog.current?.close()}
>
<X size={20} />
</button>
{navigation(true)}
</dialog>
<div className={s.workspace}>
<header className={s.topbar}>
<div className={s.breadcrumb}>
<button
type="button"
className={s.mobileMenu}
aria-label="打开导航"
onClick={() => dialog.current?.showModal()}
>
<Menu size={20} />
</button>
<span className={s.breadcrumbSpace}>工作空间</span>
<ChevronRight size={14} />
<strong>{title}</strong>
</div>
<div className={s.topbarActions}>
<Link className={s.topbarProtocol} href="/protocol">
<span className={s.statusDot} />
Linked Guidance 0.1
</Link>
{router.pathname !== "/write" && (
<Link className={s.primaryButton} href="/write">
<Plus size={16} />
创建文档
</Link>
)}
{!user && !loading && (
<Link className={s.topbarLogin} href="/account">
<LogIn size={16} />
<span>登录</span>
</Link>
)}
</div>
</header>
<main className={s.content} id="workspace-content">
{children}
</main>
<footer className={s.workspaceFooter}>
<span>织页 · 让知识按需连接</span>
<Link href="/protocol">
文档协议 <ArrowUpRight size={12} />
</Link>
</footer>
</div>
</div>
</AccountContext.Provider>
);
}
@@ -0,0 +1,190 @@
import { useId, useRef, useState, type ReactNode } from "react";
import {
BookOpen,
Check,
CheckCircle2,
Copy,
FileCheck2,
FileText,
Info,
TriangleAlert,
} from "lucide-react";
import type { Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
export const kindNames: Record<Guidance["kind"], string> = {
guide: "操作指导",
context: "背景知识",
evidence: "事实依据",
};
export function KindIcon({
kind,
size = 18,
}: {
kind: Guidance["kind"];
size?: number;
}) {
const Icon =
kind === "guide" ? FileText : kind === "context" ? BookOpen : FileCheck2;
return <Icon size={size} aria-hidden="true" />;
}
export function KindBadge({ kind }: { kind: Guidance["kind"] }) {
return (
<span className={`${s.kindBadge} ${s[kind]}`}>
<KindIcon kind={kind} size={13} />
{kindNames[kind]}
</span>
);
}
export function CopyButton({
value,
label = "复制地址",
className = "",
}: {
value: string | (() => string);
label?: string;
className?: string;
}) {
const [state, setState] = useState<"idle" | "copied" | "error">("idle");
async function copy() {
try {
await navigator.clipboard.writeText(
typeof value === "function" ? value() : value,
);
setState("copied");
} catch {
setState("error");
}
}
return (
<span className={s.copyControl}>
<button
type="button"
className={className || s.secondaryButton}
onClick={() => void copy()}
>
{state === "copied" ? <Check size={15} /> : <Copy size={15} />}
<span>{state === "copied" ? "已复制" : label}</span>
</button>
<span className={s.srOnly} role="status">
{state === "error"
? "无法自动复制,请选择下方地址手动复制。"
: state === "copied"
? "已复制到剪贴板。"
: ""}
</span>
{state === "error" && (
<small className={s.copyError}>请手动复制下方地址</small>
)}
</span>
);
}
export function Notice({
children,
tone = "info",
}: {
children: ReactNode;
tone?: "info" | "success" | "error" | "warning";
}) {
const Icon =
tone === "error" || tone === "warning"
? TriangleAlert
: tone === "success"
? CheckCircle2
: Info;
return (
<div
className={`${s.notice} ${s["notice" + tone]}`}
role={tone === "error" ? "alert" : "status"}
>
<Icon size={18} />
<div>{children}</div>
</div>
);
}
export function PageHeading({
title,
description,
eyebrow,
children,
}: {
title: string;
description?: string;
eyebrow?: string;
children?: ReactNode;
}) {
return (
<header className={s.pageHeading}>
<div>
{eyebrow && <span className={s.eyebrow}>{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
{children && <div className={s.headingActions}>{children}</div>}
</header>
);
}
export function EmptyState({
title,
description,
children,
}: {
title: string;
description: string;
children?: ReactNode;
}) {
return (
<div className={s.emptyState}>
<span className={s.emptyIcon}>
<BookOpen size={24} />
</span>
<h3>{title}</h3>
<p>{description}</p>
{children}
</div>
);
}
export function Tabs({
tabs,
value,
onChange,
}: {
tabs: { id: string; label: string; count?: number }[];
value: string;
onChange: (id: string) => void;
}) {
const group = useId(),
refs = useRef<(HTMLButtonElement | null)[]>([]);
return (
<div role="tablist" aria-label="内容视图" className={s.tabs}>
{tabs.map((tab, index) => (
<button
key={tab.id}
ref={(el) => {
refs.current[index] = el;
}}
id={`${group}-${tab.id}`}
role="tab"
type="button"
aria-selected={value === tab.id}
tabIndex={value === tab.id ? 0 : -1}
onClick={() => onChange(tab.id)}
onKeyDown={(e) => {
let next = index;
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
else if (e.key === "ArrowLeft")
next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
else return;
e.preventDefault();
onChange(tabs[next].id);
refs.current[next]?.focus();
}}
>
{tab.label}
{tab.count !== undefined && <span>{tab.count}</span>}
</button>
))}
</div>
);
}
+54 -14
View File
@@ -1,15 +1,55 @@
import { db,transaction } from './db';
import { documentInput } from './guidance-input';
import { findGuide,type Guidance } from '../content/guides/catalog';
export async function resolveDocument(id:string,revision?:number):Promise<Guidance|null>{
if(!id.startsWith('user-'))return findGuide(id,revision)||null;
const r=await db().query(`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,[id,revision??null]);return r.rows[0]?.content||null;
import { db, transaction } from "./db";
import { documentInput } from "./guidance-input";
import { findGuide, type Guidance } from "../content/guides/catalog";
export async function resolveDocument(
id: string,
revision?: number,
): Promise<Guidance | null> {
if (!id.startsWith("user-")) return findGuide(id, revision) || null;
const r = await db().query(
`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,
[id, revision ?? null],
);
return r.rows[0]?.content || null;
}
export async function publishDocument(owner: string, value: unknown) {
const content = documentInput.parse(value);
if (!content.id.startsWith("user-" + owner + "-")) throw Error("INVALID_ID");
return transaction(async (c) => {
await c.query(
"INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
[content.id, owner],
);
const d = (
await c.query("SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE", [
content.id,
])
).rows[0];
if (d.owner_id !== owner) throw Error("FORBIDDEN");
if (d.deleted_at || content.revision !== d.latest + 1)
throw Error("CONFLICT");
await c.query(
"INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)",
[content.id, content.revision, content],
);
await c.query("UPDATE guidance_documents SET latest=$2 WHERE id=$1", [
content.id,
content.revision,
]);
return content;
});
}
export async function withdrawDocument(owner: string, id: string) {
const r = await db().query(
"UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id",
[id, owner],
);
return !!r.rowCount;
}
export async function discoverDocuments(author?: string) {
const r = await db().query(
`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL AND ($1::uuid IS NULL OR d.owner_id=$1::uuid) ORDER BY r.created_at DESC LIMIT 100`,
[author ?? null],
);
return r.rows;
}
export async function publishDocument(owner:string,value:unknown){const content=documentInput.parse(value);if(!content.id.startsWith('user-'+owner+'-'))throw Error('INVALID_ID');return transaction(async c=>{
await c.query('INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING',[content.id,owner]);
const d=(await c.query('SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE',[content.id])).rows[0];
if(d.owner_id!==owner)throw Error('FORBIDDEN');if(d.deleted_at||content.revision!==d.latest+1)throw Error('CONFLICT');
await c.query('INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)',[content.id,content.revision,content]);await c.query('UPDATE guidance_documents SET latest=$2 WHERE id=$1',[content.id,content.revision]);return content;
});}
export async function withdrawDocument(owner:string,id:string){const r=await db().query('UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id',[id,owner]);return !!r.rowCount;}
export async function discoverDocuments(){const r=await db().query(`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 100`);return r.rows;}
+359 -23
View File
@@ -1,24 +1,360 @@
'use client';
import { useEffect, useState, type FormEvent } from 'react';
export default function AccountPage() {
const [mode,setMode]=useState('login');const [user,setUser]=useState<{email:string}|null>(null);
const [message,setMessage]=useState('');const [busy,setBusy]=useState(false);const [token,setToken]=useState('');
async function refresh(){try{const r=await fetch('/api/account/session');const data=await r.json();if(r.ok)setUser(data.user);else setMessage(data.error);}catch{setMessage('暂时无法连接账户服务');}}
useEffect(()=>{void refresh();const u=new URL(window.location.href);setToken(u.searchParams.get('verify')||'');if(u.searchParams.has('verify'))window.history.replaceState({},'', '/account');},[]);
async function action(name:string,body:object={}) {setBusy(true);setMessage('');try{const r=await fetch('/api/account/'+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();setMessage(d.error||d.message||'操作成功');if(r.ok){if(name==='verify')setToken('');await refresh();}}catch{setMessage('网络异常,请稍后重试');}finally{setBusy(false);}}
function submit(e:FormEvent<HTMLFormElement>){e.preventDefault();const f=new FormData(e.currentTarget);void action(mode,{email:f.get('email'),password:f.get('password')});}
const button={padding:'12px 20px',border:'1px solid #394b43',borderRadius:12,cursor:'pointer',background:'#173d30',color:'white'};
return <main style={{maxWidth:540,margin:'8vh auto',padding:28,fontFamily:'system-ui',color:'#18382b'}}>
<a href="/">← 返回知识站</a><h1 style={{fontSize:36,marginTop:32}}>你的知识站账户</h1>
<p>用邮箱保存你的身份。邮箱账户与管理员动态码权限独立。</p>
{token&&<section style={{padding:20,background:'#f1f5ef',borderRadius:16}}><h2>确认邮箱验证</h2><p>点击后完成验证,再使用邮箱和密码登录。</p><button disabled={busy} style={button} onClick={()=>void action('verify',{token})}>确认验证邮箱</button></section>}
{user?<section><h2>已登录</h2><p>{user.email}</p><p>邮箱已验证</p><button style={button} disabled={busy} onClick={()=>void action('logout')}>退出登录</button></section>:<>
<nav style={{display:'flex',gap:10,margin:'24px 0'}}>{[['login','登录'],['register','注册'],['resend','重发验证邮件']].map(([v,label])=><button key={v} disabled={busy} aria-pressed={mode===v} style={{...button,background:mode===v?'#173d30':'#65776c'}} onClick={()=>{setMode(v);setMessage('');}}>{label}</button>)}</nav>
<form onSubmit={submit} style={{display:'grid',gap:16}}>
<label>邮箱<input style={{display:'block',width:'100%',padding:12,border:'1px solid #b4bfb7',borderRadius:8}} required type="email" name="email" maxLength={254} autoComplete="email" /></label>
<label>密码<input style={{display:'block',width:'100%',padding:12,border:'1px solid #b4bfb7',borderRadius:8}} required type="password" name="password" minLength={12} maxLength={256} autoComplete={mode==='register'?'new-password':'current-password'} /></label>
<small>密码至少 12 个字符。注册后需先验证邮箱;验证链接有效期 30 分钟。</small>
<button style={button} disabled={busy} type="submit">{busy?'处理中…':mode==='login'?'登录':mode==='register'?'创建账户':'重发验证邮件'}</button></form></>}
<p role="status" style={{lineHeight:1.7,marginTop:24}}>{message}</p>
</main>;
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState, type FormEvent } from "react";
import {
ArrowRight,
BookOpen,
CheckCircle2,
Eye,
EyeOff,
GitBranch,
Link2,
LogOut,
MailCheck,
Waypoints,
} from "lucide-react";
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
import { Notice } from "@/components/GuidanceUI";
import s from "@/styles/guidance.module.css";
type Mode = "login" | "register" | "resend";
function localNext(value: string | string[] | undefined) {
if (
typeof value !== "string" ||
!value.startsWith("/") ||
value.startsWith("//") ||
/[\\\u0000-\u001f]/.test(value)
)
return "/write";
try {
const url = new URL(value, "https://local.invalid");
return url.origin === "https://local.invalid"
? url.pathname + url.search + url.hash
: "/write";
} catch {
return "/write";
}
}
export default function AccountPage() {
return (
<GuidanceShell title="邮箱账户">
<AccountContent />
</GuidanceShell>
);
}
function AccountContent() {
const account = useGuidanceAccount(),
router = useRouter();
const [mode, setMode] = useState<Mode>("login"),
[email, setEmail] = useState(""),
[password, setPassword] = useState(""),
[showPassword, setShowPassword] = useState(false);
const [busy, setBusy] = useState(false),
[token, setToken] = useState(""),
[feedback, setFeedback] = useState<{
tone: "success" | "error";
text: string;
} | null>(null);
useEffect(() => {
const url = new URL(window.location.href);
const value = url.searchParams.get("verify");
if (value) {
setToken(value);
url.searchParams.delete("verify");
window.history.replaceState({}, "", url.pathname + url.search + url.hash);
}
}, []);
async function action(name: Mode | "verify" | "logout", body: object = {}) {
if (busy) return;
setBusy(true);
setFeedback(null);
try {
const response = await fetch("/api/account/" + name, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || "暂时无法完成操作,请重试");
if (name === "verify") {
setToken("");
setMode("login");
setFeedback({ tone: "success", text: "邮箱已验证,现在可以登录。" });
} else if (name === "login") {
setPassword("");
setFeedback({ tone: "success", text: "登录成功,欢迎回到织页。" });
} else if (name === "logout") {
setPassword("");
setFeedback({ tone: "success", text: "已退出登录。" });
} else
setFeedback({
tone: "success",
text:
data.message ||
"如果信息符合条件,验证邮件已发送。请检查收件箱和垃圾邮件。",
});
await account.refresh();
} catch (e) {
setFeedback({
tone: "error",
text: e instanceof Error ? e.message : "网络异常,请稍后重试",
});
} finally {
setBusy(false);
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void action(mode, { email, password });
}
function switchMode(value: Mode) {
setMode(value);
setFeedback(null);
}
return (
<div className={s.accountLayout}>
<section className={s.accountIntro}>
<span className={s.accountSymbol}>
<Waypoints size={28} />
</span>
<span className={s.eyebrow}>YOUR KNOWLEDGE, CONNECTED</span>
<h1>
让有用的知识,
<br />
成为下一次行动的起点。
</h1>
<p>
用邮箱建立你的知识空间。把工作方法写成独立文档,让 Agent
在需要的时候找到它。
</p>
<div className={s.accountFeatures}>
<div>
<BookOpen size={18} />
<div>
<strong>每篇文档,都能独立使用</strong>
<p>从当前任务出发,无需加载整个集合。</p>
</div>
</div>
<div>
<GitBranch size={18} />
<div>
<strong>每次更新,都有固定版本</strong>
<p>历史引用保持可读,修改始终有迹可循。</p>
</div>
</div>
<div>
<Link2 size={18} />
<div>
<strong>知识之间,按条件连接</strong>
<p>需要更多背景时,再沿有用的链接继续阅读。</p>
</div>
</div>
</div>
</section>
<section className={s.authCard} aria-label="邮箱账户">
<h2>
{account.user
? "你的账户"
: mode === "register"
? "创建你的账户"
: mode === "resend"
? "重发验证邮件"
: "欢迎回来"}
</h2>
<p className={s.authDescription}>
{account.user
? "已准备好发布和管理你的独立文档。"
: mode === "register"
? "验证邮箱后,就可以发布第一篇文档。"
: mode === "resend"
? "输入注册时的邮箱和密码,重新获取验证链接。"
: "使用邮箱与密码登录,继续你的知识工作。"}
</p>
{token && (
<section className={s.verificationCard}>
<h3>
<MailCheck size={17} />
确认验证邮箱
</h3>
<p>验证链接有效期为 30 分钟。确认后,再使用邮箱与密码登录。</p>
<button
type="button"
className={s.primaryButton}
disabled={busy}
onClick={() => void action("verify", { token })}
>
{busy ? "正在验证…" : "确认验证邮箱"}
</button>
</section>
)}
{feedback && <Notice tone={feedback.tone}>{feedback.text}</Notice>}
{account.error && (
<Notice tone="error">
{account.error}{" "}
<button
type="button"
className={s.textLink}
onClick={() => void account.refresh()}
>
重新连接
</button>
</Notice>
)}
{account.loading ? (
<div className={s.accountLoading} aria-busy="true">
正在检查账户状态…
</div>
) : account.user ? (
<>
<div className={s.loggedUser}>
<span className={s.avatar}>
{account.user.email.slice(0, 1).toUpperCase()}
</span>
<div>
<strong>{account.user.email}</strong>
<span className={s.verifiedBadge}>
<CheckCircle2 size={13} />
邮箱已验证
</span>
</div>
</div>
<div className={s.accountInfo}>
<p>
<strong>你的内容,由你管理。</strong>
<br />
你可以发布文档、创建新版本,以及撤下自己的内容。公开文档的固定历史版本会继续保留。
</p>
</div>
<Link
className={s.primaryButton}
href={localNext(router.query.next)}
>
继续使用织页
<ArrowRight size={15} />
</Link>
<div className={s.authSecondary}>
<button
type="button"
disabled={busy}
onClick={() => void action("logout")}
>
<LogOut size={12} /> 退出登录
</button>
</div>
</>
) : (
<>
{mode !== "resend" && (
<nav className={s.authTabs} aria-label="账户操作">
<button
type="button"
aria-pressed={mode === "login"}
disabled={busy}
onClick={() => switchMode("login")}
>
登录
</button>
<button
type="button"
aria-pressed={mode === "register"}
disabled={busy}
onClick={() => switchMode("register")}
>
创建账户
</button>
</nav>
)}
<form onSubmit={submit}>
<fieldset className={s.formFieldset} disabled={busy}>
<div className={s.formField}>
<label htmlFor="account-email">邮箱地址</label>
<input
id="account-email"
className={s.input}
type="email"
name="email"
autoComplete="email"
placeholder="you@example.com"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className={s.formField}>
<label htmlFor="account-password">密码</label>
<div className={s.passwordField}>
<input
id="account-password"
className={s.input}
type={showPassword ? "text" : "password"}
name="password"
autoComplete={
mode === "register"
? "new-password"
: "current-password"
}
placeholder={
mode === "register"
? "设置至少 12 个字符的密码"
: "输入你的密码"
}
minLength={12}
maxLength={256}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
aria-label={showPassword ? "隐藏密码" : "显示密码"}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{mode === "register" && (
<small className={s.fieldHint}>
建议使用包含字母、数字的长密码。
</small>
)}
</div>
<button className={s.primaryButton} type="submit">
{busy
? "正在处理…"
: mode === "login"
? "登录"
: mode === "register"
? "创建账户"
: "发送验证邮件"}
{!busy && <ArrowRight size={15} />}
</button>
</fieldset>
</form>
<div className={s.authSecondary}>
{mode === "resend" ? (
<button
type="button"
disabled={busy}
onClick={() => switchMode("login")}
>
返回登录
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => switchMode("resend")}
>
没有收到验证邮件?重新发送
</button>
)}
</div>
<p className={s.authFootnote}>
注册后需要先验证邮箱。公开文档始终可以直接阅读。
</p>
</>
)}
</section>
</div>
);
}
@@ -1,27 +1,126 @@
import { ZodError } from 'zod';
import type { NextApiRequest,NextApiResponse } from 'next';
import { currentAccount } from '@/lib/account';
import { trustedOrigin } from '@/lib/account-crypto';
import { documentInput,parseDocumentXml } from '@/lib/guidance-input';
import { discoverDocuments,publishDocument,resolveDocument,withdrawDocument } from '@/lib/guidance-store';
import { guidanceXml } from '@/content/guides/catalog';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');const path=req.query.path||[];if(!Array.isArray(path)||path.length>1)return res.status(404).end();const id=path[0];
try{
if(req.method==='GET'){
if(!id)return res.json({documents:await discoverDocuments()});
const revision=req.query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return res.status(400).json({error:'版本无效'});
const doc=await resolveDocument(id,revision===undefined?undefined:Number(revision));if(!doc)return res.status(404).json({error:'文档或精确版本不存在'});
if(req.query.format==='xml'){res.setHeader('Content-Type','application/xml; charset=utf-8');return res.send(guidanceXml(doc));}return res.json({document:doc});}
if(!['POST','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, DELETE');return res.status(405).end();}
const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers:new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][])});
if(!trustedOrigin(request))return res.status(403).json({error:'请求来源不受信任'});
const user=await currentAccount(request);if(!user)return res.status(401).json({error:'请先登录已验证邮箱账户'});
if(req.method==='DELETE')return id&&await withdrawDocument(user.id,id)?res.json({ok:true}):res.status(404).json({error:'无权删除或不存在'});
const input=typeof req.body==='string'?parseDocumentXml(req.body):documentInput.parse(req.body);
if(id==='preview'){const links=await Promise.all(input.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return res.json({document:input,xml:guidanceXml(input),links});}
if(id&&id!==input.id)return res.status(400).json({error:'文档 ID 不匹配'});
return res.status(201).json({document:await publishDocument(user.id,input)});
}catch(e){if(e instanceof ZodError)return res.status(400).json({error:'输入不符合协议,请检查以下字段',fields:e.issues.slice(0,12).map(i=>i.path.join('.')||'文档')});const message=e instanceof Error?e.message:'';if(message==='INVALID_ID')return res.status(400).json({error:'文档 ID 必须以 user-你的账户ID- 开头,请使用编辑器生成的前缀'});if(message==='FORBIDDEN')return res.status(403).json({error:'仅作者可编辑'});if(message==='CONFLICT'||(e as {code?:string}).code==='23505')return res.status(409).json({error:'版本冲突:请加载最新版本,已发布版本不可修改'});if(e instanceof Error&&(e.name==='ZodError'||/XML|实体|元素|属性|协议|链接/.test(message)))return res.status(400).json({error:'输入不符合协议'});return res.status(503).json({error:'文档服务暂不可用'});}
import { ZodError } from "zod";
import type { NextApiRequest, NextApiResponse } from "next";
import { currentAccount } from "@/lib/account";
import { trustedOrigin } from "@/lib/account-crypto";
import { documentInput, parseDocumentXml } from "@/lib/guidance-input";
import {
discoverDocuments,
publishDocument,
resolveDocument,
withdrawDocument,
} from "@/lib/guidance-store";
import { guidanceXml } from "@/content/guides/catalog";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
res.setHeader("Cache-Control", "no-store");
const path = req.query.path || [];
if (!Array.isArray(path) || path.length > 1) return res.status(404).end();
const id = path[0];
try {
if (req.method === "GET") {
if (!id) {
const author = req.query.author;
if (
author !== undefined &&
(typeof author !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
author,
))
)
return res.status(400).json({ error: "作者 ID 无效" });
return res.json({ documents: await discoverDocuments(author) });
}
const revision = req.query.revision;
if (
revision !== undefined &&
(typeof revision !== "string" ||
!/^[1-9][0-9]{0,9}$/.test(revision) ||
Number(revision) > 2147483647)
)
return res.status(400).json({ error: "版本无效" });
const doc = await resolveDocument(
id,
revision === undefined ? undefined : Number(revision),
);
if (!doc) return res.status(404).json({ error: "文档或精确版本不存在" });
if (req.query.format === "xml") {
res.setHeader("Content-Type", "application/xml; charset=utf-8");
return res.send(guidanceXml(doc));
}
return res.json({ document: doc });
}
if (!["POST", "DELETE"].includes(req.method || "")) {
res.setHeader("Allow", "GET, POST, DELETE");
return res.status(405).end();
}
const request = new Request(
new URL(
req.url || "/",
process.env.ACCOUNT_PUBLIC_ORIGIN || "http://" + req.headers.host,
),
{
headers: new Headers(
Object.entries(req.headers).filter(
([, v]) => typeof v === "string",
) as [string, string][],
),
},
);
if (!trustedOrigin(request))
return res.status(403).json({ error: "请求来源不受信任" });
const user = await currentAccount(request);
if (!user) return res.status(401).json({ error: "请先登录已验证邮箱账户" });
if (req.method === "DELETE")
return id && (await withdrawDocument(user.id, id))
? res.json({ ok: true })
: res.status(404).json({ error: "无权删除或不存在" });
const input =
typeof req.body === "string"
? parseDocumentXml(req.body)
: documentInput.parse(req.body);
if (id === "preview") {
const links = await Promise.all(
input.links.map(async (l) => ({
...l,
resolved: !!(await resolveDocument(l.target, l.revision)),
})),
);
return res.json({ document: input, xml: guidanceXml(input), links });
}
if (id && id !== input.id)
return res.status(400).json({ error: "文档 ID 不匹配" });
return res
.status(201)
.json({ document: await publishDocument(user.id, input) });
} catch (e) {
if (e instanceof ZodError)
return res
.status(400)
.json({
error: "输入不符合协议,请检查以下字段",
fields: e.issues.slice(0, 12).map((i) => i.path.join(".") || "文档"),
});
const message = e instanceof Error ? e.message : "";
if (message === "INVALID_ID")
return res
.status(400)
.json({
error: "文档 ID 必须以 user-你的账户ID- 开头,请使用编辑器生成的前缀",
});
if (message === "FORBIDDEN")
return res.status(403).json({ error: "仅作者可编辑" });
if (message === "CONFLICT" || (e as { code?: string }).code === "23505")
return res
.status(409)
.json({ error: "版本冲突:请加载最新版本,已发布版本不可修改" });
if (
e instanceof Error &&
(e.name === "ZodError" || /XML|实体|元素|属性|协议|链接/.test(message))
)
return res.status(400).json({ error: "输入不符合协议" });
return res.status(503).json({ error: "文档服务暂不可用" });
}
}
export const config={api:{bodyParser:{sizeLimit:'128kb'}}};
export const config = { api: { bodyParser: { sizeLimit: "128kb" } } };
@@ -1,6 +1,61 @@
import type { GetServerSideProps } from 'next';
import type { Guidance } from '@/content/guides/catalog';
import styles from '@/styles/documents.module.css';
type Props={canEdit:boolean;versions:number[];doc:Guidance;links:(Guidance['links'][number]&{resolved:boolean})[]};
export default function Read({doc,links,canEdit,versions}:Props){return <main className={styles.page}><nav><a href="/documents">用户文档</a> · <a href={canEdit?'/write?id='+doc.id:'/write'}>{canEdit?'编辑我的文档':'发布文档'}</a></nav><h1>{doc.title}</h1><p>{doc.kind} · {doc.id} · 固定版本 {doc.revision}</p><p>版本:{versions.map(v=><a key={v} href={'/documents/'+doc.id+'?revision='+v}> v{v} </a>)}</p><p>{doc.summary}</p><h2>适用条件</h2><p>{doc.applies}</p><p className={styles.body}>{doc.body}</p><ol>{doc.steps.map((s,i)=><li key={i}>{s}</li>)}</ol><h2>条件链接</h2>{links.map((l,i)=><article key={i}><p>条件:{l.when} · {l.relation}</p>{l.resolved?<a href={(l.target.startsWith('user-')?'/documents/':'/guide/')+l.target+'?revision='+l.revision}>{l.label} · v{l.revision}</a>:<p>{l.label}:精确版本缺失,请跳过</p>}</article>)}<h2>检查项</h2><ul>{doc.checks.map((s,i)=><li key={i}>{s}</li>)}</ul><a href={'/api/documents/'+doc.id+'?revision='+doc.revision+'&format=xml'}>读取规范 XML</a>{canEdit&&<p><button onClick={async()=>{if(!confirm('撤下后仍保留固定历史版本,确定撤下?'))return;const r=await fetch('/api/documents/'+doc.id,{method:'DELETE'});alert(r.ok?'已撤下,历史版本仍可访问':(await r.json()).error);}}>作者撤下文档</button></p>}</main>;}
export const getServerSideProps:GetServerSideProps<Props>=async({params,query,res,req})=>{res.setHeader('Cache-Control','no-store');const revision=query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return {notFound:true};const {resolveDocument}=await import('@/lib/guidance-store');const doc=await resolveDocument(String(params?.id),revision===undefined?undefined:Number(revision));if(!doc)return {notFound:true};const links=await Promise.all(doc.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));const {db}=await import('@/lib/db');const {currentAccount}=await import('@/lib/account');const user=await currentAccount(new Request('http://internal',{headers:{cookie:req.headers.cookie||''}}));const owner=await db().query('SELECT owner_id FROM guidance_documents WHERE id=$1',[doc.id]);const rows=await db().query('SELECT revision FROM guidance_revisions WHERE document_id=$1 ORDER BY revision DESC',[doc.id]);return {props:{doc,links,canEdit:!!user&&owner.rows[0]?.owner_id===user.id,versions:rows.rows.map(r=>r.revision)}};};
import type { GetServerSideProps } from "next";
import GuidanceReader, { type ReaderProps } from "@/components/GuidanceReader";
export default function Read(props: ReaderProps) {
return <GuidanceReader {...props} />;
}
export const getServerSideProps: GetServerSideProps<ReaderProps> = async ({
params,
query,
res,
req,
}) => {
res.setHeader("Cache-Control", "no-store");
const revision = query.revision;
if (
revision !== undefined &&
(typeof revision !== "string" ||
!/^[1-9][0-9]{0,9}$/.test(revision) ||
Number(revision) > 2147483647)
)
return { notFound: true };
const { resolveDocument } = await import("@/lib/guidance-store");
const document = await resolveDocument(
String(params?.id),
revision === undefined ? undefined : Number(revision),
);
if (!document) return { notFound: true };
const links = await Promise.all(
document.links.map(async (link) => ({
...link,
resolved: !!(await resolveDocument(link.target, link.revision)),
})),
);
const { db } = await import("@/lib/db");
const { currentAccount } = await import("@/lib/account");
const user = await currentAccount(
new Request("http://internal", {
headers: { cookie: req.headers.cookie || "" },
}),
);
const owner = await db().query(
"SELECT owner_id,deleted_at FROM guidance_documents WHERE id=$1",
[document.id],
);
const rows = await db().query(
"SELECT revision,created_at FROM guidance_revisions WHERE document_id=$1 ORDER BY revision DESC",
[document.id],
);
return {
props: {
document,
source: "community",
links,
canEdit: !!user && owner.rows[0]?.owner_id === user.id,
withdrawn: !!owner.rows[0]?.deleted_at,
versions: rows.rows.map((r) => ({
revision: r.revision,
createdAt: new Date(r.created_at).toISOString(),
})),
},
};
};
@@ -1,3 +1,4 @@
import { useEffect,useState } from 'react';
import styles from '@/styles/documents.module.css';
export default function Documents(){const [docs,setDocs]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[error,setError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setDocs(b.documents);}).catch(e=>setError(e.message));},[]);return <main className={styles.page}><nav><a href="/">首页</a> · <a href="/write">发布文档</a></nav><h1>用户发布的文档</h1><p>{error}</p>{docs.map(d=><article key={d.id}><h2><a href={'/documents/'+d.id+'?revision='+d.revision}>{d.title}</a></h2><p>{d.kind} · v{d.revision}</p><p>{d.summary}</p></article>)}</main>;}
import DocumentDirectory from "@/components/DocumentDirectory";
export default function Documents() {
return <DocumentDirectory communityOnly />;
}
+44 -9
View File
@@ -1,9 +1,44 @@
import type {GetServerSideProps} from 'next';
import Head from 'next/head';
import Link from 'next/link';
import {useState} from 'react';
import GuidanceShell from '@/components/GuidanceShell';
import {findGuide,guides,guidanceXml,type Guidance} from '@/content/guides/catalog';
import s from '@/styles/guidance.module.css';
export default function Guide({guide:g,backlinks}:{guide:Guidance;backlinks:{id:string;title:string}[]}){const[xml,setXml]=useState(false);return <GuidanceShell><Head><title>{g.title} · 织页</title></Head><main className={s.detail}><p className={s.meta}><Link href="/">文档</Link> / protocol-examples / {g.id}</p><span className={s.eyebrow}>{g.kind.toUpperCase()} · REVISION {g.revision}</span><h1>{g.title}</h1><p>{g.summary}</p><div className={s.tabs}><button aria-pressed={!xml} onClick={()=>setXml(false)}>阅读</button><button aria-pressed={xml} onClick={()=>setXml(true)}>XML 源文档</button><a href={`/api/guides/${g.id}?revision=${g.revision}&format=xml`}>单篇机器接口 ↗</a></div><div className={s.detailGrid}><article className={s.prose}>{xml?<pre>{guidanceXml(g)}</pre>:<><h2>何时使用</h2><p>{g.applies}</p><h2>指导正文</h2><p>{g.body}</p><h2>建议步骤</h2><ol>{g.steps.map(t=><li key={t}>{t}</li>)}</ol><h2>完成检查</h2><ul>{g.checks.map(t=><li key={t}>{t}</li>)}</ul><h2>继续探索</h2>{g.links.map(l=><Link className={s.related} key={l.target} href={`/guide/${l.target}?revision=${l.revision}`}><strong>{l.label} ↗</strong><span>当{l.when} · {l.relation} · r{l.revision}</span></Link>)}{!g.links.length&&<p>这篇文档可独立使用,没有建议继续读取的链接。</p>}</>}</article><aside className={s.note}><h3>采用这篇文档</h3><p>把单篇机器接口交给 Agent。仅当链接条件适用时,再读取关联文档。</p><p>读取链接不授予执行权限。背景和依据不作为操作指令。</p><h3>被这些文档引用</h3>{backlinks.map(b=><p key={b.id}><Link href={`/guide/${b.id}`}>{b.title} ↗</Link></p>)}{!backlinks.length&&<p>暂时没有反向链接。</p>}</aside></div></main></GuidanceShell>}
export const getServerSideProps:GetServerSideProps=async({params,query})=>{const r=query.revision;if(r!==undefined&&(typeof r!=='string'||!/^\d+$/.test(r)))return{notFound:true};const guide=findGuide(String(params?.id),r===undefined?undefined:Number(r));return guide?{props:{guide,backlinks:guides.filter(g=>g.links.some(l=>l.target===guide.id&&l.revision===guide.revision)).map(({id,title})=>({id,title}))}}:{notFound:true};};
import type { GetServerSideProps } from "next";
import GuidanceReader, { type ReaderProps } from "@/components/GuidanceReader";
import { findGuide, guides } from "@/content/guides/catalog";
export default function Guide(props: ReaderProps) {
return <GuidanceReader {...props} />;
}
export const getServerSideProps: GetServerSideProps<ReaderProps> = async ({
params,
query,
}) => {
const r = query.revision;
if (r !== undefined && (typeof r !== "string" || !/^\d+$/.test(r)))
return { notFound: true };
const document = findGuide(
String(params?.id),
r === undefined ? undefined : Number(r),
);
if (!document) return { notFound: true };
return {
props: {
document,
source: "example",
versions: [{ revision: document.revision }],
links: document.links.map((link) => ({
...link,
resolved: !!findGuide(link.target, link.revision),
})),
backlinks: guides
.filter((g) =>
g.links.some(
(link) =>
link.target === document.id &&
link.revision === document.revision,
),
)
.map(({ id, title, revision }) => ({
id,
title,
revision,
source: "example",
})),
},
};
};
+3 -74
View File
@@ -1,75 +1,4 @@
import Head from 'next/head';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect,useState,type FormEvent} from 'react';
import GuidanceShell from '@/components/GuidanceShell';
import {guides} from '@/content/guides/catalog';
import s from '@/styles/guidance.module.css';
type PublishedDocument={id:string;revision:number;title:string;summary:string;kind:string};
const categories=[['all','全部文档'],['guide','操作指导'],['context','背景知识'],['evidence','事实依据']];
export default function Home(){
const router=useRouter();
const [published,setPublished]=useState<PublishedDocument[]>([]);
const [loadError,setLoadError]=useState('');
const [loading,setLoading]=useState(true);
const [q,setQ]=useState('');
const [kind,setKind]=useState('all');
useEffect(()=>{
let active=true;
fetch('/api/documents').then(async r=>{
const b=await r.json();if(!r.ok)throw Error(b.error);
if(active)setPublished(b.documents);
}).catch(e=>{if(active)setLoadError(e.message);}).finally(()=>{if(active)setLoading(false);});
return()=>{active=false;};
},[]);
useEffect(()=>{
if(!router.isReady)return;
setQ(typeof router.query.q==='string'?router.query.q:'');
setKind(typeof router.query.kind==='string'&&categories.some(([key])=>key===router.query.kind)?router.query.kind:'all');
},[router.isReady,router.query.q,router.query.kind]);
function rememberFilters(query:string,category:string){
const next:{q?:string;kind?:string}={};
if(query)next.q=query;if(category!=='all')next.kind=category;
void router.push({pathname:'/',query:next},undefined,{shallow:true,scroll:false});
}
function search(e:FormEvent){e.preventDefault();rememberFilters(q,kind);}
function chooseKind(value:string){setKind(value);rememberFilters(q,value);}
function clearFilters(){setQ('');setKind('all');rememberFilters('','all');}
const matches=(g:{title:string;summary:string;kind:string;tags?:string[]})=>(kind==='all'||g.kind===kind)&&`${g.title} ${g.summary} ${g.tags?.join(' ')||''}`.toLowerCase().includes(q.trim().toLowerCase());
const items=guides.filter(matches),userItems=published.filter(matches);
const total=items.length+userItems.length;
return <GuidanceShell>
<Head><title>织页 · 为 Agent 找到下一页</title></Head>
<section className={s.hero}>
<span className={s.eyebrow}>A WEB OF USEFUL KNOWLEDGE</span>
<h1>一个起点,<br/>连接下一种可能。</h1>
<p>每一篇文档,都能独立帮助你的 Agent。<br/>从当前问题出发,沿着有用的链接继续探索。</p>
<form className={s.search} role="search" onSubmit={search}>
<span aria-hidden="true">⌕</span><input aria-label="搜索文档" placeholder="搜索文档、任务或一种想法…" value={q} onChange={e=>setQ(e.target.value)}/><button type="submit">搜索</button>
</form>
<div className={s.topics}>{['代码审查','规划','验证'].map(t=><button key={t} onClick={()=>{setQ(t);rememberFilters(t,kind);}}>{t} ↗</button>)}</div>
</section>
<main className={s.layout}>
<aside className={s.sidebar}>
<h2>文档目录</h2>
{categories.map(([key,label])=><button key={key} aria-pressed={kind===key} onClick={()=>chooseKind(key)}>{label}<span>{[...guides,...published].filter(g=>key==='all'||g.kind===key).length}</span></button>)}
<p>一个文档就是一个入口。<br/>集合是组织方式,<br/>无需整套安装。</p>
</aside>
<section aria-label="文档搜索结果">
<div className={s.sectionTitle}><p role="status">{loading?'正在加载用户文档…':loadError?`协议样例 ${items.length} 篇;用户文档加载失败`:`找到 ${total} 篇文档`}</p>{(q||kind!=='all')&&<button type="button" onClick={clearFilters}>清除所有筛选</button>}</div>
{loadError&&<p role="alert">用户文档暂未加载:{loadError}。下方仍可阅读协议样例。</p>}
{!loading&&!loadError&&total===0&&<p>没有匹配文档,请尝试其他关键词或清除筛选。</p>}
<div className={s.sectionTitle}><h2>用户发布 <small>{userItems.length} 篇</small></h2><Link href="/write">发布一篇文档 ↗</Link></div>
<div className={s.list}>{userItems.map(g=><article className={s.row} key={g.id}><div className={s.icon}>↗</div><div><div className={s.meta}>{g.id} · {g.kind} · v{g.revision}</div><h3><Link href={`/documents/${g.id}?revision=${g.revision}`}>{g.title}</Link></h3><p>{g.summary}</p></div></article>)}</div>
{!loading&&!loadError&&!published.length&&<p>还没有用户文档。你可以发布第一篇,或先阅读下方样例。</p>}
<div className={s.sectionTitle}><h2>从这里开始 <small>{items.length} 篇</small></h2><span>协议样例 · v0.1</span></div>
<div className={s.list}>{items.map(g=><article className={s.row} key={g.id}><div className={s.icon}>{g.kind==='guide'?'↗':'≋'}</div><div><div className={s.meta}>织页 / protocol-examples · r{g.revision}</div><h3><Link href={`/guide/${g.id}`}>{g.title}</Link></h3><p>{g.summary}</p><div className={s.tags}>{g.tags.map(t=><span key={t}>{t}</span>)}<span>↗ {g.links.length} 个关联</span></div></div></article>)}</div>
</section>
<aside className={s.right}>
<div className={s.manifest}><span className={s.eyebrow}>DOCUMENTS, CONNECTED.</span><h2>知识不必<br/>打包带走。</h2><p>读一页,解决眼前的问题。<br/>需要更多时,再打开下一页。</p><Link href="/protocol">了解文档协议 ↗</Link></div>
<div className={s.note}><h3>为两种 Agent 而写</h3><p>Coding agent 的工作方法,Personal agent 的日常指导,都可以从一篇文档开始。</p><h3>这是一个早期原型</h3><p>已验证邮箱用户可以发布独立文档,更新会创建不可变版本。下方协议样例与真实用户文档分别展示。</p></div>
</aside>
</main>
</GuidanceShell>;
import DocumentDirectory from "@/components/DocumentDirectory";
export default function Home() {
return <DocumentDirectory />;
}
+202 -5
View File
@@ -1,5 +1,202 @@
import Head from 'next/head';
import GuidanceShell from '@/components/GuidanceShell';
import {guides,guidanceXml} from '@/content/guides/catalog';
import s from '@/styles/guidance.module.css';
export default function Protocol(){return <GuidanceShell><Head><title>Linked Guidance 0.1 · 织页</title></Head><main className={s.detail}><span className={s.eyebrow}>AN OPEN EXPERIMENT · 0.1</span><h1>让每一页,都能成为起点。</h1><p>Linked Guidance 协议草案 · 名称待定</p><article className={s.prose}><h2>从文档出发,按需连接</h2><p>每篇文档都有独立正文、适用条件和固定版本。集合只是整理方式,不要求整套安装。Agent 先阅读一篇,再判断链接是否有助于当前任务。</p><h2>从哪里开始</h2><p><a href="/agent/index.md">Agent 阅读入口 ↗</a> · <a href="/documents">真实用户文档 ↗</a> · <a href="/write">发布文档 ↗</a></p><h2>一个最小示例</h2><pre>{guidanceXml(guides[0])}</pre><h2>三种关系</h2><p>guide:可供采用的指导;context:背景知识;evidence:事实依据。背景和依据不会自动成为指令。</p><h2>读取约定</h2><ol><li>判断入口文档是否适合当前任务。</li><li>只跟随满足 when 条件且有帮助的链接。</li><li>按文档与版本去重;默认最多 8 篇、深度 3 层。</li><li>固定版本缺失不退回最新版;预算耗尽时报告不完整。</li></ol><h2>权限不沿链接传播</h2><p>文档不会改变用户授权或运行环境约束。0.1 仅引用本站文档 id,不自动请求任意外部 URL,不执行 XML 实体或脚本。</p><h2>协议状态</h2><p>这是待验证原型,并非现有 Agent 原生标准。当前提供单篇 XML 接口、已验证邮箱作者发布与固定版本历史。跨站发现与原生 Agent 适配仍待实现。</p><p>研究起点:<a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f">Karpathy 的 LLM Wiki 原始提案 ↗</a>。XML 语法是本项目提出的设计。</p></article></main></GuidanceShell>}
import Link from "next/link";
import {
ArrowUpRight,
BookOpen,
Bot,
Braces,
GitBranch,
Link2,
ShieldCheck,
Target,
} from "lucide-react";
import GuidanceShell from "@/components/GuidanceShell";
import { CopyButton, KindBadge, PageHeading } from "@/components/GuidanceUI";
import { guides, guidanceXml } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
const sections = [
["idea", "独立文档"],
["start", "开始读取"],
["syntax", "文档语法"],
["relations", "三种关系"],
["reading", "读取约定"],
["permissions", "权限边界"],
["status", "协议状态"],
];
export default function Protocol() {
const xml = guidanceXml(guides[0]);
return (
<GuidanceShell title="文档协议">
<PageHeading
title="从一篇文档,连接下一种可能。"
description="Linked Guidance 0.1 · 为独立指导文档设计的实验协议"
eyebrow="DOCUMENTATION"
>
<a
href="/agent/index.md"
target="_blank"
rel="noreferrer"
className={s.secondaryButton}
>
<Bot size={15} />
Agent 读取入口
<ArrowUpRight size={13} />
</a>
</PageHeading>
<div className={s.protocolLayout}>
<article className={`${s.panel} ${s.protocolArticle}`}>
<section id="idea" className={s.proseSection}>
<h2>
<BookOpen size={17} />
文档是起点,集合只是组织方式
</h2>
<p>
每篇文档都有完整正文、适用条件和固定版本。Coding agent 与 personal
agent
都可以从一篇文档开始;需要更多帮助时,再判断关联链接是否适合当前任务。无需把一整组文档一起安装或加载。
</p>
</section>
<section id="start" className={s.proseSection}>
<h2>
<Target size={17} />
从哪里开始
</h2>
<p>
先通过目录发现摘要,再读取一篇文档的固定版本。人的阅读界面与 Agent
的 XML 接口引用同一份内容。
</p>
<div className={s.buttonRow} style={{ marginTop: 16 }}>
<Link className={s.secondaryButton} href="/">
探索文档
<ArrowUpRight size={13} />
</Link>
<Link className={s.secondaryButton} href="/documents">
社区文档
<ArrowUpRight size={13} />
</Link>
<Link className={s.secondaryButton} href="/write">
发布文档
<ArrowUpRight size={13} />
</Link>
</div>
<code className={s.machineAddress}>/agent/index.md</code>
<CopyButton
label="复制 Agent 入口"
value={() => window.location.origin + "/agent/index.md"}
/>
</section>
<section id="syntax" className={s.proseSection}>
<h2>
<Braces size={17} />
一份可独立读取的文档
</h2>
<p>
XML 描述文档本身,以及何时可能需要其他文档。固定 revision
保留精确引用;未知版本与缺失目标需要明确处理。
</p>
<pre className={s.codeBlock}>{xml}</pre>
<CopyButton label="复制示例 XML" value={xml} />
</section>
<section id="relations" className={s.proseSection}>
<h2>
<Link2 size={17} />
把关系写清楚
</h2>
<p>
链接是有条件的阅读建议,不是强制依赖。when
用自然语言说明何时可能需要目标文档。
</p>
<div className={s.relationGrid}>
<div>
<KindBadge kind="guide" />
<p>可供采用的工作方法。仍需先判断是否适用,并遵守用户授权。</p>
</div>
<div>
<KindBadge kind="context" />
<p>用于理解任务的背景知识,不会自动成为操作指令。</p>
</div>
<div>
<KindBadge kind="evidence" />
<p>为判断提供事实与依据。引用时需要保留实际来源和版本。</p>
</div>
</div>
</section>
<section id="reading" className={s.proseSection}>
<h2>
<GitBranch size={17} />
按需读取,固定版本
</h2>
<ol>
<li>先看入口文档的适用条件,再读正文与步骤。</li>
<li>只跟随 when 条件成立、且能补充当前任务所需知识的链接。</li>
<li>
以文档 ID 与版本去重,默认读取预算为 8 篇、最大深度 3 层。
</li>
<li>
固定版本缺失时不静默回退最新版;遇到循环或预算耗尽时停止扩展,并说明未完成的读取。
</li>
</ol>
</section>
<section id="permissions" className={s.proseSection}>
<h2>
<ShieldCheck size={17} />
权限不会沿链接传播
</h2>
<p>
读取文档不等于接受其中所有指令。用户任务和运行环境规则始终优先,文档不能要求获取秘密、扩大权限或改变目标。0.1
只引用本站逻辑 ID,不自动请求任意外部 URL,也不执行 XML
实体或脚本。
</p>
</section>
<section id="status" className={s.proseSection}>
<h2>协议状态</h2>
<p>
Linked Guidance 是本项目提出的实验协议,并非现有 Agent
已原生支持的标准。当前支持独立 XML/JSON
读取、已验证邮箱作者发布,以及不可变版本;跨站发现与原生 Agent
适配仍待实现。
</p>
<div className={s.protocolCallout}>
<p>
研究起点:
<a
className={s.textLink}
href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f"
target="_blank"
rel="noreferrer"
>
Karpathy 的 LLM Wiki 提案
<ArrowUpRight size={11} />
</a>
。本项目在持久交叉引用的基础上,提出独立指导单元、条件链接、关系类型和版本约定。
</p>
</div>
</section>
</article>
<aside className={s.readingSidebar}>
<section className={s.sideCard}>
<h3>本页目录</h3>
<nav className={s.toc} aria-label="协议目录">
{sections.map(([id, label]) => (
<a href={"#" + id} key={id}>
{label}
</a>
))}
</nav>
</section>
<section className={s.sideCard}>
<h3>开始编写你的第一篇</h3>
<p>从适用条件与清晰正文开始。关联链接可以在需要时再补充。</p>
<Link
className={s.textLink}
href="/write"
style={{ marginTop: 14 }}
>
创建文档
<ArrowUpRight size={12} />
</Link>
</section>
</aside>
</div>
</GuidanceShell>
);
}
+728 -13
View File
@@ -1,14 +1,729 @@
import { useEffect,useState } from 'react';
import type { Guidance } from '@/content/guides/catalog';
import styles from '@/styles/documents.module.css';
const blank:Guidance={id:'user-',revision:1,kind:'guide',title:'',summary:'',applies:'',body:'',steps:[],checks:[],links:[],tags:[]};
export default function Write(){
const [busy,setBusy]=useState(false),[published,setPublished]=useState<{id:string;revision:number}|null>(null);
const [doc,setDoc]=useState(blank),[xml,setXml]=useState(''),[mode,setMode]=useState(false),[message,setMessage]=useState(''),[preview,setPreview]=useState<{document:Guidance;xml:string;links:{label:string;when:string;resolved:boolean}[]}|null>(null);
useEffect(()=>{setPreview(null);},[doc,xml,mode]);
useEffect(()=>{fetch('/api/account/session').then(r=>r.json()).then(b=>{if(b.user){const id=new URLSearchParams(window.location.search).get('id');if(id)fetch('/api/documents/'+encodeURIComponent(id)).then(responseBody).then(b=>setDoc({...b.document,revision:b.document.revision+1})).catch(e=>setMessage(e.message));else setDoc(d=>({...d,id:'user-'+b.user.id+'-'}));}else setMessage('请先登录并验证邮箱,再发布文档。');}).catch(()=>setMessage('账户服务暂不可用,请稍后重试。'));},[]);
async function responseBody(r:Response){const b=await r.json().catch(()=>({error:'服务返回异常,请稍后重试'}));if(!r.ok)throw Error((b.error||'请求失败')+(b.fields?.length?':'+b.fields.join('、'):''));return b;}
async function load(){setBusy(true);setPreview(null);try{const b=await responseBody(await fetch('/api/documents/'+encodeURIComponent(doc.id)));setDoc({...b.document,revision:b.document.revision+1});setMessage('已加载,保存将创建新版本');}catch(e){setMessage(e instanceof Error?e.message:'加载失败');}finally{setBusy(false);}}
async function submit(action:string){setBusy(true);setMessage('处理中…');setPreview(null);setPublished(null);try{const b=await responseBody(await fetch('/api/documents'+(action==='preview'?'/preview':''),{method:'POST',headers:{'Content-Type':mode?'application/xml':'application/json'},body:mode?xml:JSON.stringify(doc)}));if(action==='preview'){setPreview(b);setMessage('预览成功,尚未发布');}else{setDoc({...b.document,revision:b.document.revision+1});setPublished({id:b.document.id,revision:b.document.revision});if(mode)setXml(xml.replace(/revision="[1-9][0-9]*"/,`revision="${b.document.revision+1}"`));setMessage('已发布版本 '+b.document.revision);}}catch(e){setMessage(e instanceof Error?e.message:'请求失败,请稍后重试');}finally{setBusy(false);}}
return <main className={styles.page}><nav><a href="/">首页</a> · <a href="/documents">用户文档</a> · <a href="/account">邮箱账户</a></nav><h1>发布独立文档</h1><p>需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。</p><fieldset disabled={busy}><label><input type="checkbox" checked={mode} onChange={e=>{setMode(e.target.checked);setPreview(null);}}/> 使用协议 XML(zh-CN)</label>{mode?<label>XML 输入<textarea rows={22} value={xml} onChange={e=>{setXml(e.target.value);setPreview(null);}}/></label>:<><label>文档 ID(保留账户前缀,在末尾填写短名)<input value={doc.id} onChange={e=>setDoc({...doc,id:e.target.value})}/></label><button disabled={busy} onClick={load}>加载最新版本以编辑</button><label>待发布版本<input type="number" min={1} value={doc.revision} onChange={e=>setDoc({...doc,revision:Number(e.target.value)})}/></label><label>种类<select value={doc.kind} onChange={e=>setDoc({...doc,kind:e.target.value as Guidance['kind']})}><option>guide</option><option>context</option><option>evidence</option></select></label>{(['title','summary','applies','body'] as const).map(k=><label key={k}>{{title:'标题',summary:'摘要',applies:'适用条件',body:'正文'}[k]}<textarea value={doc[k]} onChange={e=>{setDoc({...doc,[k]:e.target.value});setPreview(null);}}/></label>)}{(['steps','checks'] as const).map(k=><label key={k}>{k==='steps'?'步骤':'检查项'}(每行一项)<textarea value={doc[k].join('\n')} onChange={e=>setDoc({...doc,[k]:e.target.value.split('\n').filter(Boolean)})}/></label>)}<h2>条件链接</h2>{doc.links.map((l,i)=><fieldset key={i}>{(['target','revision','relation','when','label'] as const).map(k=><label key={k}>{k}<input value={l[k]} onChange={e=>setDoc({...doc,links:doc.links.map((v,j)=>j===i?{...v,[k]:k==='revision'?Number(e.target.value):e.target.value}:v)})}/></label>)}<button onClick={()=>setDoc({...doc,links:doc.links.filter((_,j)=>j!==i)})}>移除链接</button></fieldset>)}<button onClick={()=>setDoc({...doc,links:[...doc.links,{target:'',revision:1,relation:'context',when:'',label:''}]})}>添加精确版本链接</button></>}<p><button disabled={busy} onClick={()=>submit('preview')}>校验并预览</button> <button disabled={busy} onClick={()=>submit('publish')}>发布新版本</button></p></fieldset><p role="status">{message}</p>{published&&<p><a href={'/documents/'+published.id+'?revision='+published.revision}>打开已发布文档 v{published.revision} ↗</a></p>}{preview&&<article><h2>{preview.document.title}</h2><p>{preview.document.summary}</p><p>适用:{preview.document.applies}</p><p className={styles.body}>{preview.document.body}</p>{preview.document.steps.map((s,i)=><p key={i}>{i+1}. {s}</p>)}{preview.links.map((l,i)=><p key={i}>{l.label} · 条件:{l.when} · {l.resolved?'精确版本可用':'目标缺失,读取时需跳过'}</p>)}<pre>{preview.xml}</pre></article>}</main>;
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import {
ArrowUpRight,
Braces,
CheckCircle2,
FileText,
Globe2,
Link2,
ListChecks,
Plus,
ShieldCheck,
Trash2,
} from "lucide-react";
import GuidanceShell, { useGuidanceAccount } from "@/components/GuidanceShell";
import {
EmptyState,
KindBadge,
Notice,
PageHeading,
kindNames,
} from "@/components/GuidanceUI";
import { guidanceXml, type Guidance } from "@/content/guides/catalog";
import s from "@/styles/guidance.module.css";
const blank: Guidance = {
id: "",
revision: 1,
kind: "guide",
title: "",
summary: "",
applies: "",
body: "",
steps: [],
checks: [],
links: [],
tags: [],
};
type Preview = {
document: Guidance;
xml: string;
links: { label: string; when: string; resolved: boolean }[];
};
function fieldName(path: string) {
const labels: Record<string, string> = {
id: "文档短名",
revision: "版本",
kind: "文档类型",
title: "标题",
summary: "一句话摘要",
applies: "适用条件",
body: "正文",
steps: "建议步骤",
checks: "完成检查",
tags: "标签",
target: "目标文档 ID",
relation: "关系类型",
when: "读取条件",
label: "链接标题",
};
const [group, index, field] = path.split(".");
if (group === "links")
return (
"关联文档" +
(index === undefined ? "" : " " + (Number(index) + 1)) +
(field ? " · " + (labels[field] || "内容") : "")
);
return (
(labels[group] || "文档内容") +
(index !== undefined ? " " + (Number(index) + 1) : "")
);
}
async function responseBody(response: Response) {
const body = await response
.json()
.catch(() => ({ error: "服务返回异常,请稍后重试" }));
if (!response.ok)
throw Error(
(body.error || "请求失败") +
(body.fields?.length
? ":" + body.fields.map(fieldName).join("、")
: ""),
);
return body;
}
export default function Write() {
return (
<GuidanceShell title="发布文档">
<Editor />
</GuidanceShell>
);
}
function Editor() {
const account = useGuidanceAccount(),
router = useRouter(),
dialog = useRef<HTMLDialogElement>(null);
const [doc, setDoc] = useState<Guidance>(blank),
[xml, setXml] = useState(""),
[mode, setMode] = useState<"form" | "xml">("form"),
[busy, setBusy] = useState(false),
[loading, setLoading] = useState(false),
[error, setError] = useState(""),
[preview, setPreview] = useState<Preview | null>(null),
[published, setPublished] = useState<{
id: string;
revision: number;
} | null>(null),
[dirty, setDirty] = useState(true);
const prefix = account.user ? "user-" + account.user.id + "-" : "",
editId = typeof router.query.id === "string" ? router.query.id : "";
useEffect(() => {
if (!router.isReady || !account.user) return;
let active = true;
setError("");
setPublished(null);
setPreview(null);
setMode("form");
setDirty(true);
if (!editId) {
setDoc({
...blank,
id:
"user-" +
account.user.id +
"-document-" +
Math.random().toString(16).slice(2, 8),
});
setLoading(false);
return;
}
if (!editId.startsWith("user-" + account.user.id + "-")) {
setError("只能编辑你自己发布的文档。");
setDoc(blank);
return;
}
setLoading(true);
fetch("/api/documents/" + encodeURIComponent(editId))
.then(responseBody)
.then((body) => {
if (active)
setDoc({ ...body.document, revision: body.document.revision + 1 });
})
.catch((e) => {
if (active) {
setError(e.message);
setDoc(blank);
}
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [account.user?.id, editId, router.isReady]);
function change<K extends keyof Guidance>(key: K, value: Guidance[K]) {
setDoc((current) => ({ ...current, [key]: value }));
setPreview(null);
setDirty(true);
setPublished(null);
}
async function request(action: "preview" | "publish" | "convert") {
setBusy(true);
setError("");
setPreview(null);
try {
const body = await responseBody(
await fetch(
"/api/documents" + (action === "publish" ? "" : "/preview"),
{
method: "POST",
headers: {
"Content-Type":
mode === "xml" ? "application/xml" : "application/json",
},
body: mode === "xml" ? xml : JSON.stringify(normalized()),
},
),
);
if (action === "convert") {
setDoc(body.document);
setMode("form");
} else if (action === "preview") {
setPreview(body);
dialog.current?.showModal();
} else {
setDoc({ ...body.document, revision: body.document.revision + 1 });
setXml(
guidanceXml({
...body.document,
revision: body.document.revision + 1,
}),
);
setPublished({
id: body.document.id,
revision: body.document.revision,
});
setDirty(false);
}
} catch (e) {
setError(e instanceof Error ? e.message : "请求失败,请稍后重试");
} finally {
setBusy(false);
}
}
function normalized() {
return {
...doc,
steps: doc.steps.filter((step) => step.trim()),
checks: doc.checks.filter((check) => check.trim()),
tags: doc.tags.filter((tag) => tag.trim()),
};
}
function selectXml() {
setXml(guidanceXml(normalized()));
setMode("xml");
setPreview(null);
}
const authorized = account.user?.verified_at;
return (
<>
<PageHeading
title="发布独立文档"
description="一篇文档,一项清晰的指导。按需关联,让 Agent 自由选择。"
eyebrow={editId ? "UPDATE DOCUMENT" : "NEW DOCUMENT"}
/>
{account.loading ? (
<div className={s.accountLoading}>正在读取账户…</div>
) : !authorized ? (
<section className={`${s.panel} ${s.publishGate}`}>
<EmptyState
title={account.user ? "验证邮箱后继续" : "登录后开始编写"}
description={
account.error ||
"公开阅读无需登录。发布和维护文档,需要一个已验证的邮箱账户。"
}
>
<Link className={s.primaryButton} href="/account?next=%2Fwrite">
<ShieldCheck size={15} />
{account.user ? "前往验证邮箱" : "登录 / 创建账户"}
</Link>
</EmptyState>
</section>
) : (
<>
{error && <Notice tone="error">{error}</Notice>}
{published && (
<Notice tone="success">
版本 v{published.revision} 已公开发布。
<div className={s.successActions}>
<Link
href={
"/documents/" +
published.id +
"?revision=" +
published.revision
}
>
查看已发布版本
<ArrowUpRight size={12} />
</Link>
<Link href="/documents?scope=mine">查看我的文档</Link>
</div>
</Notice>
)}
{loading ? (
<div className={s.accountLoading}>正在加载最新版本…</div>
) : (
doc.id && (
<fieldset className={s.formFieldset} disabled={busy}>
<div className={s.editorTabs} aria-label="编辑方式">
<button
type="button"
aria-pressed={mode === "form"}
onClick={() => {
if (mode === "xml") void request("convert");
}}
>
可视化编辑
</button>
<button
type="button"
aria-pressed={mode === "xml"}
onClick={() => {
if (mode === "form") selectXml();
}}
>
协议 XML
</button>
</div>
<div className={s.formGrid}>
<div className={s.formSections}>
{mode === "xml" ? (
<section className={`${s.panel} ${s.formPanel}`}>
<h2 className={s.formSectionTitle}>
<Braces size={17} />
协议源文档
</h2>
<p className={s.formDescription}>
可直接粘贴完整
XML。切回可视化编辑时,会先校验并解析当前内容;校验失败时保留输入。
</p>
<label className={s.formField}>
<span className={s.fieldLabel}>XML 输入</span>
<textarea
className={`${s.textarea} ${s.xmlInput}`}
spellCheck={false}
value={xml}
onChange={(e) => {
setXml(e.target.value);
setDirty(true);
setPreview(null);
setPublished(null);
}}
/>
</label>
</section>
) : (
<>
<section className={`${s.panel} ${s.formPanel}`}>
<h2 className={s.formSectionTitle}>
<FileText size={17} />
基本信息
</h2>
<p className={s.formDescription}>
帮助读者和 Agent 判断,这篇文档是否适合当前任务。
</p>
<div className={s.fieldColumns}>
<label className={s.formField}>
<span className={s.fieldLabel}>文档短名</span>
<input
className={s.input}
aria-label="文档短名"
maxLength={37}
pattern="[a-z0-9-]+"
value={doc.id.slice(prefix.length)}
disabled={!!editId || doc.revision > 1}
onChange={(e) =>
change("id", prefix + e.target.value)
}
/>
<small className={s.fieldHint}>
小写字母、数字和连字符。发布后不可修改。
</small>
</label>
<label className={s.formField}>
<span className={s.fieldLabel}>文档类型</span>
<select
className={s.select}
value={doc.kind}
onChange={(e) =>
change(
"kind",
e.target.value as Guidance["kind"],
)
}
>
{Object.entries(kindNames).map(
([id, label]) => (
<option value={id} key={id}>
{label}
</option>
),
)}
</select>
</label>
</div>
{(["title", "summary", "applies"] as const).map(
(key) => (
<label className={s.formField} key={key}>
<span className={s.fieldLabel}>
{
{
title: "标题",
summary: "一句话摘要",
applies: "适用条件",
}[key]
}
<span className={s.required}>*</span>
</span>
{key === "title" ? (
<input
className={s.input}
value={doc[key]}
placeholder="例如:审查一次代码改动"
onChange={(e) =>
change(key, e.target.value)
}
/>
) : (
<textarea
className={s.textarea}
rows={2}
value={doc[key]}
placeholder={
key === "summary"
? "这篇文档可以帮助完成什么?"
: "什么情况下应该阅读?哪些情况不适用?"
}
onChange={(e) =>
change(key, e.target.value)
}
/>
)}
</label>
),
)}
<label className={s.formField}>
<span className={s.fieldLabel}>标签</span>
<input
className={s.input}
value={doc.tags.join(", ")}
placeholder="代码审查, 开发协作"
onChange={(e) =>
change(
"tags",
e.target.value
.split(/[,,]/)
.map((tag) => tag.trim()),
)
}
/>
<small className={s.fieldHint}>
使用逗号分隔。
</small>
</label>
</section>
<section className={`${s.panel} ${s.formPanel}`}>
<h2 className={s.formSectionTitle}>
<ListChecks size={17} />
指导内容
</h2>
<p className={s.formDescription}>
正文应当完整、可独立理解。步骤和检查项可以留空。
</p>
<label className={s.formField}>
<span className={s.fieldLabel}>
正文<span className={s.required}>*</span>
</span>
<textarea
className={`${s.textarea} ${s.bodyInput}`}
value={doc.body}
placeholder="写下清晰、具体的指导…"
onChange={(e) => change("body", e.target.value)}
/>
</label>
{(["steps", "checks"] as const).map((key) => (
<label className={s.formField} key={key}>
<span className={s.fieldLabel}>
{key === "steps" ? "建议步骤" : "完成检查"}
</span>
<textarea
className={s.textarea}
aria-label={
key === "steps" ? "建议步骤" : "完成检查"
}
rows={3}
value={doc[key].join("\n")}
onChange={(e) =>
change(key, e.target.value.split("\n"))
}
/>
<small className={s.fieldHint}>
每行一项,空行会在提交时忽略。
</small>
</label>
))}
</section>
<section className={`${s.panel} ${s.formPanel}`}>
<h2 className={s.formSectionTitle}>
<Link2 size={17} />
条件链接
<span className={s.neutralBadge}>
{doc.links.length}
</span>
</h2>
<p className={s.formDescription}>
仅当条件适用时,才建议读取关联文档。请指定固定版本;无需链接也可以独立发布。
</p>
{!doc.links.length && (
<div className={s.linkEmpty}>
还没有关联文档。你可以先完成正文,再为需要的场景补充链接。
</div>
)}
{doc.links.map((link, index) => (
<div className={s.linkEditor} key={index}>
<div className={s.linkEditorHeader}>
关联文档 {index + 1}
<button
type="button"
className={s.quietButton}
aria-label={"移除关联文档 " + (index + 1)}
onClick={() =>
change(
"links",
doc.links.filter((_, i) => i !== index),
)
}
>
<Trash2 size={13} />
移除
</button>
</div>
<div className={s.fieldColumns}>
{(
[
"target",
"revision",
"relation",
"label",
"when",
] as const
).map((key) => (
<label className={s.formField} key={key}>
<span className={s.fieldLabel}>
{
{
target: "目标文档 ID",
revision: "固定版本",
relation: "关系类型",
label: "链接标题",
when: "什么情况下读取",
}[key]
}
</span>
{key === "relation" ? (
<select
className={s.select}
value={link.relation}
onChange={(e) =>
change(
"links",
doc.links.map((item, i) =>
i === index
? {
...item,
relation: e.target
.value as Guidance["kind"],
}
: item,
),
)
}
>
{Object.entries(kindNames).map(
([id, label]) => (
<option value={id} key={id}>
{label}
</option>
),
)}
</select>
) : (
<input
className={s.input}
type={
key === "revision" ? "number" : "text"
}
min={key === "revision" ? 1 : undefined}
value={link[key]}
onChange={(e) =>
change(
"links",
doc.links.map((item, i) =>
i === index
? {
...item,
[key]:
key === "revision"
? Number(e.target.value)
: e.target.value,
}
: item,
),
)
}
/>
)}
</label>
))}
</div>
</div>
))}
<button
type="button"
className={s.secondaryButton}
onClick={() =>
change("links", [
...doc.links,
{
target: "",
revision: 1,
relation: "context",
when: "",
label: "",
},
])
}
>
<Plus size={14} />
添加条件链接
</button>
</section>
</>
)}
</div>
<aside className={s.publishSidebar}>
<section className={s.sideCard}>
<h3>
<Globe2 size={15} />
发布设置
</h3>
<div className={s.publishMeta}>
公开文档 · 待发布版本 v{doc.revision}
</div>
<KindBadge kind={doc.kind} />
<ul className={s.publishChecklist}>
<li className={s.completed}>
<CheckCircle2 size={14} />
邮箱已验证
</li>
<li>
<CheckCircle2 size={14} />
发布后任何人均可读取
</li>
<li>
<CheckCircle2 size={14} />
每次发布创建不可变版本
</li>
</ul>
<p>内容暂存在当前页面,请在离开前发布或自行保存 XML。</p>
<div className={s.publishButtons}>
<button
type="button"
className={s.secondaryButton}
onClick={() => void request("preview")}
>
校验并预览
</button>
<button
type="button"
className={s.primaryButton}
disabled={!dirty}
onClick={() => void request("publish")}
>
{busy
? "正在处理…"
: doc.revision === 1
? "发布文档"
: "发布新版本"}
</button>
</div>
</section>
<section className={s.sideCard}>
<h3>独立使用,按需连接</h3>
<p>
关系类型和适用条件帮助 Agent
选择下一篇文档。链接不会继承账户权限,也不要求整组加载。
</p>
<Link className={s.textLink} href="/protocol">
阅读协议说明
<ArrowUpRight size={12} />
</Link>
</section>
</aside>
</div>
</fieldset>
)
)}
</>
)}
<dialog
className={`${s.modal} ${s.largeModal}`}
ref={dialog}
aria-labelledby="preview-title"
>
<div className={s.modalBody}>
<h2 id="preview-title">发布预览</h2>
<p>校验已通过,内容尚未发布。请确认正文和关联条件。</p>
</div>
{preview && (
<div className={s.previewBody}>
<KindBadge kind={preview.document.kind} />
<h2 className={s.previewTitle}>{preview.document.title}</h2>
<p className={s.formDescription}>{preview.document.summary}</p>
<div className={s.appliesCard}>
<p>{preview.document.applies}</p>
</div>
<div className={s.proseBody}>{preview.document.body}</div>
{preview.document.steps.length > 0 && (
<ol className={s.stepList}>
{preview.document.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
)}
{preview.document.checks.length > 0 && (
<ul className={s.checkList}>
{preview.document.checks.map((check, i) => (
<li key={i}>{check}</li>
))}
</ul>
)}
{preview.links.map((link, i) => (
<div className={s.relationCard} key={i}>
<div>
<strong>{link.label}</strong>
<p>当{link.when}</p>
<small>
{link.resolved ? "固定版本可用" : "目标缺失,读取时需跳过"}
</small>
</div>
</div>
))}
</div>
)}
<div className={s.modalActions}>
<button
type="button"
className={s.secondaryButton}
onClick={() => dialog.current?.close()}
>
返回编辑
</button>
</div>
</dialog>
</>
);
}
File diff suppressed because one or more lines are too long
@@ -27,6 +27,7 @@ export async function verifyGuidancePublishing(base:string){
const created=await request('','POST',guidanceXml(httpDoc),{'Content-Type':'application/xml'});assert.equal(created.status,201,await created.clone().text());
assert.equal((await request('','POST',httpDoc)).status,409);
assert.equal((await request('','POST',{...httpDoc,id:'user-'+other+'-http'})).status,400);
const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400);
const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/<tag>往返标签<\/tag>/);
const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
// The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner.
@@ -35,7 +36,7 @@ export async function verifyGuidancePublishing(base:string){
const page=await browser.newPage();
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
await page.goto(base+'/',{waitUntil:'domcontentloaded'});
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).textContent(),httpDoc.title);
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
const search=page.getByRole('textbox',{name:'搜索文档'});
await search.fill(httpDoc.title);await search.press('Enter');
@@ -45,14 +46,14 @@ export async function verifyGuidancePublishing(base:string){
await page.reload({waitUntil:'domcontentloaded'});
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
assert.equal(await search.inputValue(),httpDoc.title);
await page.getByRole('button',{name:'事实依据'}).click();
await page.getByRole('tab',{name:/事实依据/}).click();
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence');
await page.getByText('没有匹配文档,请尝试其他关键词或清除筛选。',{exact:true}).waitFor();
await page.getByRole('heading',{name:'没有匹配文档',exact:true}).waitFor();
await page.getByRole('button',{name:'清除所有筛选'}).click();
await page.waitForURL(url=>url.search==='');
await page.locator(link).waitFor({state:'visible'});
assert.equal(await search.inputValue(),'');
await page.locator('header a[href="/documents"]').click();await page.locator(link).waitFor({state:'visible'});
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
await page.locator(link).click();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
}finally{await browser.close();}
@@ -0,0 +1,326 @@
/** UI contract checks use isolated API fixtures; real storage/auth remain covered by guidance-integration.ts. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright-core";
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
const base = process.env.WORKSPACE_TEST_URL;
const owner = "11111111-1111-4111-8111-111111111111";
const authored: Guidance = {
id: "user-" + owner + "-review",
revision: 1,
kind: "guide",
title: "准备一次清晰的代码审查",
summary: "用明确的问题和证据,完成一次有用的代码审查。",
applies: "提交合并请求前",
body: "先理解改动目标,再核对实现与验证。",
steps: [],
checks: [],
links: [],
tags: [],
};
test(
"workspace filters, reading tabs, account states and editor work on desktop and mobile",
{ skip: !base },
async () => {
const browser = await chromium.launch({
executablePath:
process.env.CHROMIUM_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
args: ["--no-sandbox"],
});
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
let signedIn = false,
previewError = false,
requestedAuthor: string | null = null,
published: Guidance | null = null;
const errors: string[] = [];
try {
await context.route("**/api/account/session", (route) =>
route.fulfill({
json: {
user: signedIn
? {
id: owner,
email: "editor@example.com",
verified_at: "2026-10-29T00:00:00Z",
}
: null,
},
}),
);
await context.route(
(url) => url.pathname === "/api/documents",
async (route) => {
if (route.request().method() === "GET") {
requestedAuthor = new URL(route.request().url()).searchParams.get(
"author",
);
return route.fulfill({
json: { documents: [{ ...authored, author: owner }] },
});
}
published = documentInput.parse(route.request().postDataJSON());
return route.fulfill({ status: 201, json: { document: published } });
},
);
await context.route("**/api/documents/preview", (route) => {
if (previewError)
return route.fulfill({
status: 400,
json: { error: "输入不符合协议", fields: ["applies"] },
});
let document: Guidance;
try {
document =
route.request().headers()["content-type"] === "application/xml"
? parseDocumentXml(route.request().postData() || "")
: documentInput.parse(route.request().postDataJSON());
} catch {
return route.fulfill({
status: 400,
json: { error: "输入不符合协议" },
});
}
return route.fulfill({
json: {
document,
xml: guidanceXml(document),
links: document.links.map((link) => ({ ...link, resolved: true })),
},
});
});
await context.route("**/api/documents/" + authored.id, (route) =>
route.fulfill({ json: { document: authored } }),
);
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(base!, { waitUntil: "networkidle" });
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(await page.title(), "探索文档 · 织页");
const search = page.getByRole("textbox", { name: "搜索文档" });
await search.fill("不存在的文档");
await search.press("Enter");
await page
.getByRole("heading", { name: "没有匹配文档", exact: true })
.waitFor();
await page.reload({ waitUntil: "networkidle" });
assert.equal(await search.inputValue(), "不存在的文档");
await page
.getByRole("button", { name: "清除所有筛选", exact: true })
.click();
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.getByRole("tab", { name: /事实依据/ }).focus();
await page.keyboard.press("Home");
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
fullPage: true,
});
}
await page.goto(base + "/guide/review-changes?revision=1", {
waitUntil: "networkidle",
});
await page.getByRole("tab", { name: "XML 源文档" }).click();
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
await page.getByRole("tab", { name: /版本记录/ }).click();
await page.getByText("当前阅读", { exact: true }).waitFor();
await page.getByRole("tab", { name: "文档概览" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
fullPage: true,
});
await page.goto(base + "/account", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
await page
.getByLabel("密码", { exact: true })
.fill("a-long-example-password");
await page.getByRole("button", { name: "显示密码" }).click();
assert.equal(
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
"text",
);
await page.getByRole("button", { name: "隐藏密码" }).click();
await page.getByLabel("密码", { exact: true }).fill("");
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
fullPage: true,
});
await page.goto(base + "/write", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
signedIn = true;
await page.reload({ waitUntil: "networkidle" });
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
await page
.getByLabel("一句话摘要", { exact: false })
.fill("可独立使用的指导");
await page
.getByLabel("适用条件", { exact: false })
.fill("检查界面流程时");
await page
.getByLabel("正文", { exact: false })
.fill("先判断适用条件,再阅读正文。");
await page
.getByLabel("建议步骤", { exact: true })
.fill("第一步\n\n第二步");
previewError = true;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
previewError = false;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("dialog").waitFor({ state: "visible" });
await page.getByRole("button", { name: "返回编辑" }).click();
assert.equal(
await page.getByLabel("正文", { exact: false }).inputValue(),
"先判断适用条件,再阅读正文。",
);
await page.getByRole("button", { name: "添加条件链接" }).click();
await page.getByRole("button", { name: "移除关联文档 1" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
fullPage: true,
});
}
await page.getByRole("button", { name: "发布文档", exact: true }).click();
await page
.getByRole("status")
.filter({ hasText: "已公开发布" })
.waitFor();
assert.ok(published);
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
assert.equal(
(published as Guidance).id,
"user-" + owner + "-workspace-test",
);
await page.goto(base + "/documents?scope=mine", {
waitUntil: "networkidle",
});
assert.equal(requestedAuthor, owner);
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.goto(base + "/write?id=" + authored.id, {
waitUntil: "networkidle",
});
assert.equal(
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
true,
);
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
authored.title,
);
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
const xmlInput = page.getByLabel("XML 输入");
assert.match(await xmlInput.inputValue(), /revision="2"/);
await xmlInput.fill(
(await xmlInput.inputValue()).replace(
authored.title,
"通过 XML 更新的文档",
),
);
await page
.getByRole("button", { name: "可视化编辑", exact: true })
.click();
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
"通过 XML 更新的文档",
);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(base!, { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.getByRole("button", { name: "打开导航" }).click();
await page
.getByRole("dialog", { name: "工作区导航" })
.waitFor({ state: "visible" });
await page.keyboard.press("Escape");
assert.equal(
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
false,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
fullPage: true,
});
await page.goto(base + "/guide/review-changes?revision=1", {
waitUntil: "networkidle",
});
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
fullPage: true,
});
await page.goto(base + "/account", { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.goto(base + "/write", { waitUntil: "networkidle" });
await page.getByLabel("文档短名", { exact: true }).waitFor();
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
await page.getByLabel("XML 输入").waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
fullPage: true,
});
await page.goto(base + "/protocol", { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
fullPage: true,
});
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
},
);
@@ -31,6 +31,8 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
await verifyEmailAccounts();
await verifyGuidancePublishing(base);
const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'});
const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks');
await verifyFlatNoteMigration();
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
@@ -214,8 +216,8 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
}
const machineIndex=await fetch(base+'/api/guides');assert.equal(machineIndex.status,200);assert.equal((await machineIndex.json()).protocol,'linked-guidance/0.1');
const home=await fetch(base+'/').then(r=>r.text());
for(const href of ['/documents','/write','/protocol','/guide/review-changes'])assert.ok(home.includes('href=\"'+href+'\"'),`Guidance homepage links to ${href}`);
assert.ok(home.includes('用户发布'));assert.ok(home.includes('协议样例'));
for(const href of ['/documents','/write','/protocol','/guide/review-changes?revision=1'])assert.ok(home.includes('href=\"'+href+'\"'),`Guidance homepage links to ${href}`);
assert.ok(home.includes('社区发布'));assert.ok(home.includes('协议样例'));
// Legacy media and skill functionality stays covered by its own routes above and below.
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');
+38
View File
@@ -0,0 +1,38 @@
# 交付验收记录
所有 PR 由用户手动合并,只有 main 更新触发 ces-control CI/CD。用户后续明确允许在当前项目目录内本地开发和测试;本次 SaaS 重构使用独立工作区,结束后清理自建临时依赖、预览和构建产物。
## 已部署功能的实际证据
2026-10-29,main run 508 / job 556 成功:类型检查、51 项单元测试、生产构建、smoke、真实 PostgreSQL/S3/Chromium 集成、认证检查与生产部署全部通过。验收入口为 http://8.211.154.134:18131/ ,健康检查返回应用版本 `9a603cf87e9b726a566ae68f4f479030e0d02629`。随后 #18 仅修改工作流与说明,没有应用代码变更。
| 用户要求 | 已有证据 |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 研究 LLM Wiki 与新语法 | protocol.md 引用 Karpathy 原始 gist;独立 XML、条件链接、关系、预算与版本规则是本项目提出的实验协议,不声称 Agent 已原生支持 |
| SkillHub 截图与设计分析 | chat 中已截取首页、目录、详情概述/文件/版本/评论/评测、登录、作者、发布限制;补充插件、SkillPay、大赛、使用指南、专家包与企业专区首屏。受限发布编辑器未完整验收 |
| 单篇可独立采用 | 公开入口 GET/HEAD、Agent 索引、样例与持久文档读取均已验证;不要求安装整个集合 |
| 条件链接与固定版本 | 实际预览解析 source-trust 固定版本;缺失版本返回 404;XML 条件和标签往返已验证 |
| 邮箱、密码、邮箱验证 | 指定真实收件箱收到注册邮件并确认位于 INBOX;未验证登录 401,验证一次 200、重复消费 400,验证后登录 200 |
| 普通账户权限隔离 | 普通邮箱会话调用旧管理员写 API 返回 401;匿名预览 401,跨来源写入 403 |
| 真实发布与版本 | 实际发布 v1/v2,旧版本正文保持不变,旧版本号重复发布返回 409;目录返回最新版本 |
| 撤下与退出 | 验收文档已撤下,最新入口 404、固定历史版本 200、公开目录不再列出;注销后会话为空 |
| 保留旧界面 | src/legacy/WaterfallHome.tsx 与旧 Shell/ExploreFeed 保留,新入口不再使用旧首页;旧内容路由仍在集成测试覆盖范围内 |
| 独立分支、人工合并 | #6–#18 由用户合并;当前 SaaS 重构使用 codex/wiki-saas,交付 PR 等待用户手动合并 |
## 邮件与敏感信息
SMTP 运行时配置位于服务器 root 所有、0600 权限的 /etc/prompt.env。密码、注册验证令牌与测试账户密码不进入仓库、PR、截图或报告。早期 SMTP 诊断邮件只证明投递;上述后续真实注册验证才证明完整账户流程。验收文档已撤下;固定历史版本按产品约定保留。
## 本次 SaaS 视觉重构
详细设计参考与页面规范见 ../wiki-saas/DESIGN.md。重构范围为公共外壳、目录、社区/我的文档、阅读、账户、编辑与协议页。
本地独立预览中的 UI 回归使用明确的 API 测试夹具,覆盖 URL 筛选恢复、清除、键盘标签导航、阅读/XML/版本、账户模式与密码可见性、发布登录门槛、表单错误保留输入、校验预览、发布输入规范化、现有文档编辑、XML 往返、移动端导航关闭及无横向溢出。该测试不代替上述真实数据库、SMTP 和权限验证;已接入 main 集成测试流程。
类型检查、51 项单元测试、生产构建与 standalone smoke 已通过。生产构建保留 preview.ts 动态文件跟踪的既有警告。
实际检查了 1440px 桌面目录/阅读/账户/编辑器,以及 390px 手机目录/阅读/XML 编辑器/协议截图。原生桌面浏览器当时因 Mac 锁定不可操作,截图由项目隔离的无头 Chromium 回归测试生成,未将其表述为线上部署验收。
## 剩余交付边界
SaaS 改版尚未合并和部署。PR 就绪后等待用户手动合并,届时由正常 main CI/CD 运行真实存储集成与部署,再在指定服务器复核新页面。不得把旧版本的成功 CI 或本地夹具截图称为新版线上验证。
+67
View File
@@ -0,0 +1,67 @@
# 页面、组件与功能设计
## 产品单位
本项目的核心单位是一个可直接打开、引用和采用的文档版本。作者、集合和站点负责组织与发现;它们不构成必须一起下载或使用的安装包。用户把单篇地址交给 coding agent 或 personal agent,agent 根据适用条件阅读,再判断是否需要打开某个关联文档。
暂用“织页 / Linked Guidance”作为工作名称,不视为已确定品牌。第一版以文档工作台为主,保留可搜索目录、清晰身份、阅读视图、源文档视图和关联信息。
## 参考取舍
| 观察到的模式 | SkillHub 实际表现 | 本项目的对应设计 |
| --- | --- | --- |
| 首页导向 | 巨大标题、安装提示词、服务轮播、趋势榜 | 解释独立文档概念,直接提供搜索与入口文档 |
| 可扫描目录 | 名称、分类、摘要、图标、收藏与下载 | 名称、适用任务、关系类型、版本;未产生的热度不展示 |
| 详情身份 | 命名空间、作者、版本、认证、评分 | 稳定文档 ID、作者身份、精确版本;身份不等于内容正确 |
| 阅读与源内容 | 概述、文件标签、Markdown 正文 | 阅读与 XML 标签;XML 是指导交换格式 |
| 固定操作侧栏 | 复制安装提示词、ZIP、Agent 安装 | 复制单篇引用、查看实际机器接口、解释何时读下一页 |
| 版本追溯 | 时间线、版本说明与下载 | 不可变修订号、版本导航;固定链接不悄悄升级 |
| 作者空间 | 头像/简介/关注/统计、作品搜索与排序 | 作者身份与其文档;集合是可选导航层 |
| 发布入口 | 匿名时显示登录要求 | 已验证邮箱身份;结构化表单与 XML 输入共用校验 |
Hugging Face 和 GitHub 的身份栏、版本与内容/文件分栏可用作信息层级参考。本项目不将仓库树作为默认知识组织入口。已补截评论与评测详情:评论采用独立卡片、热门/最新排序、点赞与回复;评测采用 TRACE 五维说明、雷达图、综合评级和分项进度条。两者的证据来源不同,不能将自动评分直接视为实际任务成功率。
## 页面与路由
| 页面 | 用户要完成的事 | 页面需要的状态 |
| --- | --- | --- |
| 首页 `/` | 找到与眼前任务相关的一篇文档 | 搜索、种类筛选、真实结果、空结果、加载失败、协议样例标识 |
| 单篇 `/documents/:id?revision=N` | 阅读与引用一个准确版本 | 身份、种类、适用条件、正文、步骤、检查项、条件链接、版本导航 |
| 编辑 `/write` | 创建一篇或更新自己的文档 | 已验证/未登录、表单/XML 模式、校验错误、预览、发布中、冲突、成功链接 |
| 文档目录 `/documents` | 浏览真实用户内容 | 摘要结果、空库、服务不可用 |
| 账户 `/account` | 注册、验证、登录与退出 | 密码规则、邮件发送反馈、过期/重复验证、限流、未验证登录、会话状态 |
| 协议 `/protocol` | 了解如何让 agent 采用单篇内容 | 实际 XML 示例、关系含义、版本行为、权限边界、协议状态 |
| 样例 `/guide/:id` | 在没有账户时理解协议 | 明确样例来源,独立阅读与机器接口,不混充用户发布内容 |
作者资料、集合、收藏和讨论是可扩展的独立产品功能,不以静态不可用控件占位;本次核心交付仍需要先打通发布、采用与版本闭环。
## 组件清单
- `GuidanceShell`:统一身份、探索、发布、协议与账户导航;窄屏仍可访问全部入口。
- 搜索与分类:筛选必须作用于真实文档;样例区与用户内容分开标记。
- 文档摘要行:稳定标题、简短适用说明、种类与版本;整行信息层级一致。
- 文档阅读器:正文优先,条件链接明确说明“何时有用”,不自动展开全部目标。
- 采用侧栏:单篇机器地址、固定版本提示、来源及采用边界。
- 修订导航:当前修订与可访问历史;缺失修订明确返回错误。
- 指导编辑器:表单与 XML 共用服务端 schema;任何输入变化都使旧预览失效。
- 预览与校验反馈:显示实际解析结果、关联目标可用性;预览不能写入发布表。
- 账户表单:提交中防重、网络失败反馈、邮箱验证后的明确下一步。
## 核心流程
```mermaid
flowchart LR
A[当前任务] --> B[发现一篇文档]
B --> C[阅读适用条件与正文]
C --> D{是否缺少相关知识}
D -->|否| E[在用户授权范围内采用]
D -->|是| F[判断条件链接]
F --> G[读取目标固定版本]
G --> C
```
读取过程需要去重与预算,防止循环。链接是阅读候选,不能改变用户任务或工具权限。事实依据与背景知识保留各自含义,即使由 agent 阅读也不会自动变成执行指令。
## 验收重点
视觉上优先验证桌面阅读宽度、窄屏导航、正文换行、长 ID/XML 溢出和表单错误可见性。行为上优先验证注册投递与邮箱验证、作者发布、非作者拒绝、冲突反馈、旧版本稳定及缺失链接的表现。远程 CI 与实际服务器结果都要保存;截图或测试中出现问题就继续修订,而不是把原型样例数量当作交付完成度。
+47 -4
View File
@@ -2,9 +2,9 @@
## 研究状态(2026-10-29)
已阅读 Karpathy LLM Wiki 原始提案,检查用户提供的 Hugging Face Qwen 页面与 GitHub Chromium 页面。已通过 Chrome 截取 SkillHub 首页、目录、详情概述三个画面,读取文件树状态;截图显示在主 chat 工具结果中,未导出为仓库图片。文件页截图请求超时。研究仍未覆盖所有页面,不宣称已完成逐页分析。
已阅读 Karpathy LLM Wiki 原始提案,检查用户提供的 Hugging Face Qwen 页面与 GitHub Chromium 页面。已通过 Chrome 逐项截取核心页面与状态,具体记录见下文。截图显示在主 chat 工具结果中,未导出为仓库图片;未覆盖整个网站,发布编辑器受实名限制。
待采集:SkillHub 首页首屏和下半屏、搜索与筛选、技能详情、文档/文件视图、作者页、登录入口、发布入口、移动视图;逐页记录实际 URL、截图、布局、控件、交互和对本项目的取舍。无法匿名访问的页面明确注明登录限制,不冒充已检查。
已采集:首页首屏与下半屏、目录筛选布局、技能详情全部五个标签、企业作者页、登录弹窗、发布访问限制。移动视图尚未采集,已验证分类筛选和搜索空结果,其他筛选组合尚未完整遍历;无法访问的页面明确注明限制,不冒充已检查。
Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分栏、主内容加元数据侧栏。GitHub 可借鉴:稳定地址、版本历史、README 阅读层级、文件和讨论分离。这里应把“单篇文档”放在中心,集合只是可选组织方式;不复制仓库文件树为强制入口。
@@ -17,7 +17,7 @@ Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分
| /protocol | 展示 0.1 约定与使用边界 | 语法示例、读取流程、协议状态 |
| /account | 注册、验证邮箱、登录、退出 | 表单、错误反馈、会话状态 |
| 作者与集合 | 后续真实用户内容组织 | 作者简介、非强制集合、收藏 |
| 编辑与历史 | 后续持久化发布流程 | 编辑器、校验结果、预览、版本差异 |
| /write、/documents/:id | 持久化发布、作者更新、固定版本阅读与撤下 | 结构化/XML 编辑器、校验、预览、版本导航;差异比较后续实现 |
第一阶段提供真实可读样例和单篇机器接口。账户系统独立 PR;内容创作、收藏、讨论不可用静态按钮冒充完成。后续将原素材中的适用指导迁移为独立文档,保留原始来源,迁移前不删除。
@@ -25,7 +25,7 @@ Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分
沿用 Next.js Pages Router、TypeScript、现有 PostgreSQL、Tailwind 基础;旧首页保存到 src/legacy/WaterfallHome.tsx,原组件保留。新 UI 使用独立 CSS Module,避免影响原素材和 Studio 页面。认证保留管理员动态码,邮箱用户具有独立身份,不能获得现有管理员写权限。
构建只在用户确认的 ces-control;页面验收只访问 http://8.211.154.134:18131/。远程 CI、部署、浏览器验证分别记录结果。主 chat 获本次子任务 PR 合并授权;不把 PR 创建视为部署成功。
构建只在用户确认的 ces-control;页面验收只访问 http://8.211.154.134:18131/。远程 CI、部署、浏览器验证分别记录结果。所有 PR 均由用户手动合并,主 chat 不执行 merge 或自动合并;不把 PR 创建视为部署成功。
## 已观察的 SkillHub 页面
@@ -44,3 +44,46 @@ Hugging Face 可借鉴:owner/name 身份、标签、概览与文件/社区分
版本历史为时间线,显示当前版本标记、日期、说明、展开/下载控件;安装右栏在不同标签间保持一致。登录弹窗区分个人/团队,个人有微信和手机号模式,手机号模式包含验证码输入、协议勾选、主按钮;没有执行登录或同意协议。作者页包含头像/名称/认证/简介、关注、横向统计条、左侧排序分类、右侧搜索与作品目录。发布入口 `/dashboard/publish` 匿名访问明确显示“需要登录才能访问”,无法检查其内部编辑器,不假定内部布局。
待补:详情评论和评测标签、首页下半屏的独立截图、移动端;插件、付费服务、大赛作为非核心周边功能,不进入本次开发首版。第一版发布使用结构化指导编辑器,支持 XML 校验与预览,不沿用未知的 SkillHub 内部发布流程。
## 第三轮截图与发现
在独立 Chrome 窗口补截了详情“评论”和“评测报告”:评论输入区限制 500 字,支持表情和最多 9 张图片,下方以热门/最新排序展示独立评论卡,包含点赞与回复。没有发表评论、点赞或上传内容。评测报告使用紫色信息卡、五维雷达图、综合分数、评级摘要及分项进度条,并注明基于 AI 自动检测、结果供参考。
当前公开评论包含使用故障反馈,而页面仍展示高自动评分。本记录只说明两种信息同时存在,不核实或转述评论中的故障结论。对本项目的启发是把“作者身份”“静态校验”“真实任务验证”和“用户反馈”分别表达,不能将它们合成一个未经说明的可信徽章。
本轮发现浏览器已有用户登录状态;进入 `/dashboard/publish` 仍显示“需要完成实名认证”,尚不能查看编辑器。没有进入实名认证或提交资料。此前匿名登录限制与本轮实名限制属于不同访问状态,均须保留,不能声称已研究发布表单。
## 第四轮:首页下半屏截图与首版取舍
本轮补截首页榜单、个人与企业发布流程、商业服务区、企业展示区、专家包入口及页脚。榜单以黑底第一名大卡和两列紧凑列表突出排行;发布流程用个人/企业双列分步说明,辅以操作界面图片;商业区以灰蓝底大面板展示按调用计费的服务示意。企业区使用横排浅灰卡片,专家包以横幅和单一浏览按钮收束,页脚列出服务/隐私协议、来源和版权说明。这里只记录可见设计,不将示意收益或排名视作已核实数据。
首版采用三种可复用结构:可扫描的文档列表、宽正文加窄元数据侧栏、清晰的发布步骤及校验反馈。首页不展示无真实来源的排行、收益、企业背书或专家包。可选集合以后可以作为导航,但每篇文档始终保有独立地址、独立版本和独立适用范围,不要求整组加载。
## 当前实现与交付状态
原型、邮箱账户及持久化发布已由用户合并(PR #6/#7/#8/#9),运行时仍需 ces-control CI 成功并部署后验收。PR #11 修正首页 data URL 的旧测试预期;PR #12 修复 llms.txt、Markdown 入口和 /api/guides 的公开只读发现规则,均等待用户手动合并。单篇 XML/JSON、条件链接、固定版本与作者权限已有实现和测试代码,不等于远程验证已经通过。
## 第五轮:目录筛选与空结果
实测 `/skills?sortBy=trending` 的“所有场景分类”菜单,选择开发编程后地址变为 `/skills?category=dev-programming&sortBy=trending`,结果数量随之更新。输入不匹配关键词后,地址同时保留 keyword/category/sortBy,显示 0 个结果、空结果插画和“清除所有筛选”按钮。菜单、分类结果和空状态均已在主 chat 截图。
本项目对应修复在 PR #13:分类数量包含用户文档与样例,避免只因样例未命中而误报全局无结果;区分加载失败与真实空结果;搜索提交、分类和快捷主题写入 URL,可刷新恢复并清除。真实数据库与 Chromium 回归代码已提交,远程 CI 尚待执行。
## 第六轮:其他主要页面模板
在新建 Chrome 研究窗口补截以下页面首屏,未安装插件、购买服务、投稿或执行页面中的命令:
| 实际页面 | 可见结构 | 对本项目的取舍 |
| --- | --- | --- |
| `/plugins` | 大标题与数量、横向安装说明条、分类按钮、搜索/排序/视图切换、来源与统计列表 | 复用目录控件层级;独立文档采用读取地址,不要求安装运行时 |
| `/skillpay` | 巨大居中标题、普通/付费服务双栏对照、上架与接入流程按钮 | 说明页可用清晰对照,但本次不引入交易或调用计费 |
| `/contest` | 期数与状态、主题大标题、投稿/作品按钮、三联图片、数据及后续赛事说明 | 活动运营是独立模块;不作为文档发现或可信度的基础 |
| `/tutorials` | 左侧分组文档树、中间长文与表格、右侧本篇目录、阅读进度 | 协议与作者指南可借鉴锚点目录;文档本身不绑定整棵目录 |
| `/skillspackage` | 行业分类横栏、列表/卡片切换、专家角色与技能数量 | 只把集合视为可选导航;保留单篇独立入口 |
| `/skillspackage/tech-test-automation` | 概述/技能模块标签、串联工作步骤、右栏整包安装提示与 ZIP | 本项目使用每条链接的 when/relation/revision,Agent 根据当前任务决定下一篇,不能默认执行全组步骤 |
| `/enterprise-zone` | 精选/最新/热门三块彩色面板、行业筛选、搜索和企业卡片区域 | 作者来源可展示;认证与热门数据不能代替单篇的适用条件和实际验证 |
企业专区截图仍包含加载骨架,后续读取超时;未据此声称卡片全部加载或交互已验证。活动页截图包含入场渐显效果,只用于识别版式;没有核实其运营数字。窄屏参考尚未采集,服务器视觉验收仍须覆盖窄屏。
截至本轮,已覆盖 SkillHub 主要公开页面模板和核心详情状态;实名受限的发布编辑器、个人后台及全部筛选组合未遍历。截图保留在主 chat 工具结果中。组件/页面规划见 design-system.md,研究观察与功能验证分开记录。
+61
View File
@@ -0,0 +1,61 @@
# Wiki image retention on ces-control
Wiki CI builds one runtime image and one integration-test image per revision. Web
and worker share the runtime image. Tags can share an image ID; displayed image
sizes include shared layers and must not be added to estimate reclaimed space.
## Policy
- Retain the current release and the two preceding distinct successful releases.
`scripts/deploy.sh` records success in `/opt/prompt/successful-releases` only after
all existing deployment checks pass. The ledger is replaced atomically.
- The first three successful deployments bootstrap this ledger. Until then,
retain all runtime images, since old release directories alone do not prove
successful deployment. No historical success is guessed from directory dates.
- Keep the test images corresponding to retained runtime releases: Studio verification
and publishing scripts use them as operational tools. Remove other successful
job test images unless a container still references them or their image ID has
another protected tag.
- Retain other runtime/test/build images for at least 72 hours for debugging.
Expired runtime images outside the three retained releases become eligible.
- Remove expired project/build-labelled exited containers only with no mounts.
Remove expired project-labelled untagged image intermediates where Docker allows.
- Always preserve images referenced by any running, created or stopped container.
This can deliberately keep more than three runtime versions.
- Preserve foreign tags/digests, unlabelled intermediates and legacy
`prompt:*` images. Legacy images require a separate reviewed migration list.
- Never use global prune, force image/container removal, or volume deletion.
Database, object storage, backups and release directories are outside this tool.
## Preview and apply
Run from the reviewed checkout on the confirmed ces-control host:
```sh
sudo python3 scripts/wiki-image-lifecycle.py preview
sudo python3 scripts/wiki-image-lifecycle.py apply
```
The default command is preview. Both print keep/delete reasons, image IDs and tags.
Preview reads Docker metadata and may create empty lock files, but never changes
Docker state. Both commands refuse to proceed if the current release metadata or
success ledger is invalid. They also refuse while another cooperating build or
deployment holds the locks.
The workflow invokes `ci --sha <full-sha>` once on main. A single exclusive
`/opt/prompt/.image-lifecycle.lock` spans builds, integration, deployment and
cleanup. Cleanup also takes the existing `.deploy.lock`, protecting manual
`deploy.sh` invocations. Use this CI entrypoint for manual builds too; raw Docker
commands and old workflow revisions do not participate in this lock. Allow old
wiki jobs to finish before first enabling apply. Other projects are out of scope.
Cleanup runs after both success and failure. Errors are reported without changing
the original build/deployment status or rolling back healthy services. Referenced
images and Docker refusal are reported and retained. Failed CI does not record a
successful release and does not immediately remove that job's test image.
After the user merges to main, policy checks run Python tests and shell syntax
checks on ces-control without building or touching Docker. The full application
build/integration/deployment also runs only on main updates, subject to its path
filters. PR events and manual dispatch do not trigger either workflow. No local
builds are needed.
+46
View File
@@ -0,0 +1,46 @@
# 织页 SaaS 界面重构
用户明确要求以正式 SaaS 产品为标准,重做当前视觉;保留独立指导文档、条件链接、固定版本与邮箱账户的功能。本次本地开发测试获用户在“重构 Studio 设计与无限画布”chat 的直接授权,最终清理自建临时产物,不删除用户原有文件。所有 main 合并仍由用户完成。
## 设计参考与取舍
- https://github.com/bergside/awesome-design-skills :阅读 professional、shadcn、clean 与 minimal 的说明,采用语义令牌、明确状态、统一间距和键盘可达性。professional 当前示例的商店品牌与黄色不适合知识工作台,不直接复制其品牌色。
- https://ui.shadcn.com/docs/components/sidebar :参考侧栏的导航分组、账户区与移动端抽屉结构,使用现有 React/CSS Modules 实现,不引入新的组件库依赖。
- https://linear.app/features :参考工作型产品的信息层级和克制的工具界面,而非复制营销首页。
- 原 SkillHub、Hugging Face、GitHub 研究继续用于文档发现、单篇读取、来源与版本的组织。集合不会变成强制加载单位。
## 视觉基础
背景 #F8F9FC,面板 #FFFFFF,正文 #182230,次级文字 #667085,分隔线 #EAECF0,主色 #4F46E5。错误/成功颜色只表达真实反馈。间距 4/8/12/16/24/32,常规正文 14px,阅读正文 15–16px,页面标题 28px。按钮圆角 8px,面板圆角 12px;不使用装饰性渐变、低对比文字、虚假图表或无行为控件。
## 页面与实现
1. 应用外壳:左侧工作区、文档导航、资源和真实账户状态;顶部面包屑与创建入口;移动端可关闭、可键盘操作的导航。
2. 目录:首页与社区/我的文档复用搜索、类型/来源筛选、排序、URL 恢复、按作者查询的我的文档(在数据库限制最近 100 篇前筛选作者)、加载/错误/空状态与文档行;数量来自实际内容。
3. 阅读页:摘要、适用条件、正文、步骤、检查项、条件链接、固定版本和 XML;读取地址可复制,作者操作具有明确反馈。
4. 发布页:按内容分组的表单,易读的条件链接编辑,结构化/XML 模式、真实校验预览与版本发布状态;普通用户不用手动拼接账户 UUID。
5. 账户页:统一表单、注册/登录/重发验证、验证码链接状态、已验证会话和退出反馈;不展示不存在的重置密码、OAuth 或计费入口。
6. 协议页:与产品一致的文档阅读布局,保留协议的实验状态与权限边界。
## 验证
在独立工作区本地运行类型、相关单测与构建,真实数据行为沿用现有隔离集成检查。通过可用的浏览器入口查看桌面与窄屏页面,检查导航、搜索/重置、表单、长 ID/链接、键盘焦点和错误反馈。最终提交完整改版 PR,等待用户手动合并后才触发 ces-control 部署;不以开发预览代替正式部署。
## 本次检查结果
- Node 24:类型检查、51 项单元测试、生产构建与 standalone smoke 通过。构建保留 preview.ts 动态文件跟踪的既有警告。
- 项目 UI 回归使用隔离 API 夹具,覆盖作者筛选、筛选 URL 恢复、空状态、键盘标签、阅读/XML/版本、账户模式、密码显示、发布门槛、错误保留输入、预览、规范化发布和结构化/XML 往返。
- 已检查 1440px 桌面与 390px 手机截图;新版真实 PostgreSQL/S3 集成尚待用户手动合并后的 main CI。此前已部署应用的真实邮件、账户、版本与权限证据见 ../linked-guidance/acceptance.md,不能替代新改版的部署验收。
- 可在运行独立预览后执行:`WORKSPACE_TEST_URL=http://127.0.0.1:3217 CHROMIUM_PATH=<chromium可执行文件> node --import tsx --test tests/guidance-workspace.test.ts`。设置 WORKSPACE_SCREENSHOT_DIR 可生成截图。该用例也随原有集成测试在 CI 隔离栈运行,未添加 PR 或手动 CI 触发器。
- 目录仅展示最近 100 篇社区文档;我的文档先在数据库筛选作者,再应用此上限。搜索与类型筛选作用于当前已加载目录,不声称全站无限搜索。
## 可审阅截图
以下为最终生产模式预览截图。社区条目、作者邮箱和编辑器内容均为明确的回归测试夹具,不是生产用户数据;它们只证明 UI 状态,不证明真实邮件投递或持久化发布。
- [桌面目录](screenshots/directory.png)
- [桌面阅读](screenshots/reader.png)
- [账户注册](screenshots/account.png)
- [结构化编辑器](screenshots/editor.png)
- [手机目录](screenshots/mobile-directory.png)
- [手机阅读](screenshots/mobile-reader.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+5 -1
View File
@@ -17,7 +17,7 @@ test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA"
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
git -c safe.directory="$PWD" fetch origin main
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/wiki-image-lifecycle.py scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
echo 'Skipping deployment: Wiki has a newer commit'; exit 0
fi
fi
@@ -135,6 +135,10 @@ if command -v systemctl >/dev/null; then
if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi
fi
echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA"
# Register only after every deployment check succeeded; ledger failure retains old images.
if ! python3 scripts/wiki-image-lifecycle.py record-success --sha "$RELEASE_SHA"; then
echo "Warning: successful-release ledger update failed; runtime cleanup stays conservative" >&2
fi
# Read-only network diagnostics: a private address alone is not a public entrypoint.
echo "Host: $(hostname)"
hostname -I || true
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Wiki-only image retention. Preview is the default; never prune the daemon."""
import argparse
from contextlib import contextmanager
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
SHA = re.compile(r"^[0-9a-f]{40}$")
PROJECT = "io.entroia.project"
ROOT = Path("/opt/prompt")
TTL = 3 * 86400
def run(args, **kwargs):
return subprocess.run(args, check=True, text=True, **kwargs)
def output(args):
return run(args, stdout=subprocess.PIPE).stdout
def timestamp(value):
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
@contextmanager
def lock(path):
# Fail rather than wait behind a build and consume the workflow timeout.
with open(path, "a") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError(f"Busy: {path}; no cleanup performed")
yield
def history(root):
path = root / "successful-releases"
values = path.read_text().splitlines() if path.exists() else []
if any(not SHA.fullmatch(value) for value in values):
raise RuntimeError("Invalid successful-releases ledger; refusing cleanup")
return list(dict.fromkeys(values))
def current_sha(root):
path = root / "current" / "release.env"
if not path.is_file():
raise RuntimeError("Missing current/release.env; refusing cleanup")
values = [line.split("=", 1)[1] for line in path.read_text().splitlines()
if line.startswith("RELEASE_SHA=")]
if len(values) != 1 or not SHA.fullmatch(values[0]):
raise RuntimeError("Invalid current release SHA; refusing cleanup")
return values[0]
def record(root, sha):
if current_sha(root) != sha:
raise RuntimeError("Only the verified current release can be recorded")
values = [sha] + [item for item in history(root) if item != sha]
temp = root / ".successful-releases.next"
temp.write_text("\n".join(values) + "\n")
os.replace(temp, root / "successful-releases")
def decision(image, referenced, retained, ready, now, completed_test=None):
"""Return keep/delete with a reviewable reason; all tags must be in scope."""
tags = image.get("RepoTags") or []
labels = image.get("Config", {}).get("Labels") or {}
if image["Id"] in referenced:
return "keep", "container reference (including stopped containers)"
parsed = []
for tag in tags:
repo, _, sha = tag.rpartition(":")
if repo not in ("waterfall-wiki", "waterfall-wiki-tests") or not SHA.fullmatch(sha):
return "keep", "foreign or non-release tag"
parsed.append((repo, sha))
if any(digest.partition("@")[0] not in ("waterfall-wiki", "waterfall-wiki-tests")
for digest in image.get("RepoDigests") or []):
return "keep", "foreign registry digest retained conservatively"
if any(sha in retained for _, sha in parsed):
return "keep", "retained release or its operational test image"
if any(repo == "waterfall-wiki" for repo, _ in parsed) and not ready:
return "keep", "bootstrap: fewer than three successful releases recorded"
if parsed and all(repo == "waterfall-wiki-tests" and sha == completed_test
for repo, sha in parsed):
return "delete", "this job's tests completed successfully"
if now - timestamp(image["Created"]) < TTL:
return "keep", "three-day build/debug window"
if parsed:
return "delete", "expired project release/test image"
if labels.get(PROJECT) == "waterfall-wiki":
return "delete", "expired project-labelled intermediate image"
return "keep", "unlabelled image: ownership unknown"
def container_expired(container, now):
labels = container.get("Config", {}).get("Labels") or {}
return (labels.get(PROJECT) == "waterfall-wiki"
and labels.get("io.entroia.wiki.build") == "true"
and container["State"]["Status"] == "exited"
and not container.get("Mounts")
and now - timestamp(container["Created"]) >= TTL)
def containers():
ids = output(["docker", "ps", "-aq"]).split()
return json.loads(output(["docker", "inspect", *ids])) if ids else []
def image_ids():
# Include labelled build intermediates, but never enumerate foreign image metadata.
ids = set()
for name in ("waterfall-wiki", "waterfall-wiki-tests"):
ids.update(output(["docker", "image", "ls", "-aq", "--no-trunc", name]).split())
ids.update(output(["docker", "image", "ls", "-aq", "--no-trunc", "--filter",
f"label={PROJECT}=waterfall-wiki"]).split())
return sorted(ids)
def cleanup(root, apply=False, completed_test=None):
# CI holds the image lock throughout build, integration, deployment and cleanup.
# Taking the deployment lock also protects direct/manual deployment invocations.
with lock(root / ".deploy.lock"):
successes = history(root)
retained = set(successes[:3]) | {current_sha(root)}
ready = len(successes) >= 3
now = datetime.now(timezone.utc).timestamp()
print("APPLY" if apply else "PREVIEW", "retained releases:", sorted(retained), flush=True)
for container in containers():
if container_expired(container, now):
print("delete-container", container["Id"], "expired, project-labelled, no mounts", flush=True)
if apply:
fresh = json.loads(output(["docker", "inspect", container["Id"]]))[0]
if container_expired(fresh, now):
run(["docker", "container", "rm", container["Id"]])
referenced = {item["Image"] for item in containers()}
ids = image_ids()
images = json.loads(output(["docker", "image", "inspect", *ids])) if ids else []
for image in images:
image_id = image["Id"]
action, reason = decision(image, referenced, retained, ready, now, completed_test)
print(action, image_id, json.dumps(image.get("RepoTags") or []), reason, flush=True)
if apply and action == "delete":
# Recheck references and tags immediately before removal. No -f or volume operations.
fresh = json.loads(output(["docker", "image", "inspect", image_id]))[0]
refs = {item["Image"] for item in containers()}
if decision(fresh, refs, retained, ready, now, completed_test)[0] != "delete":
continue
for target in fresh.get("RepoTags") or [image_id]:
result = subprocess.run(["docker", "image", "rm", target], text=True)
if result.returncode:
print("skip", target, "Docker refused removal; retained", flush=True)
break
def ci(root, sha):
"""One lock spans the whole job; failed build images get a three-day grace."""
env = dict(os.environ, WATERFALL_WIKI_IMAGE=f"waterfall-wiki:{sha}",
WATERFALL_WIKI_TEST_IMAGE=f"waterfall-wiki-tests:{sha}")
code = 1
try:
run(["docker", "compose", "--env-file", "apps/waterfall-wiki/.env.example", "config", "--quiet"])
run(["docker", "build", "--target", "integration", "-t", env["WATERFALL_WIKI_TEST_IMAGE"], "."])
run(["docker", "run", "--rm", "--entrypoint", "node", env["WATERFALL_WIKI_TEST_IMAGE"],
"--test", "tests/character-pack.test.mjs"])
run(["docker", "build", "--target", "runtime", "-t", env["WATERFALL_WIKI_IMAGE"], "."])
run(["bash", "scripts/integration.sh"], env=env)
run(["bash", "scripts/deploy.sh", sha], env=env)
code = 0
except subprocess.CalledProcessError as error:
code = error.returncode
finally:
try:
cleanup(root, apply=True, completed_test=sha if code == 0 else None)
except Exception as error:
# Housekeeping must not mask the build/deployment result or roll back healthy services.
print(f"Cleanup failed; images retained for later retry: {error}", file=sys.stderr)
return code
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", nargs="?", choices=("preview", "apply", "record-success", "ci"), default="preview")
parser.add_argument("--sha")
args = parser.parse_args()
if args.command in ("ci", "record-success") and not SHA.fullmatch(args.sha or ""):
parser.error("--sha must be a full lowercase Git SHA")
if args.command == "record-success":
# Called at the end of deploy.sh while its deployment lock is still held.
record(ROOT, args.sha)
return 0
with lock(ROOT / ".image-lifecycle.lock"):
if args.command == "ci":
return ci(ROOT, args.sha)
cleanup(ROOT, apply=args.command == "apply")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+168
View File
@@ -0,0 +1,168 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"lifecycle", Path(__file__).resolve().parents[1] / "scripts/wiki-image-lifecycle.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
NOW = m.timestamp("2026-10-29T12:00:00Z")
A, B, C, D = [letter * 40 for letter in "abcd"]
def image(tags=None, labels=None, created="2026-10-20T12:00:00Z"):
return {"Id": "sha256:example", "RepoTags": tags or [], "RepoDigests": [],
"Created": created, "Config": {"Labels": labels or {}}}
class RetentionTests(unittest.TestCase):
def decide(self, item, referenced=(), retained=(A, B, C), ready=True, completed=None):
return m.decision(item, set(referenced), set(retained), ready, NOW, completed)[0]
def test_current_and_two_previous_survive(self):
for sha in (A, B, C):
self.assertEqual(self.decide(image([f"waterfall-wiki:{sha}"])), "keep")
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"])), "delete")
def test_retained_release_test_images_remain_available_to_studio_tools(self):
for sha in (A, B, C):
self.assertEqual(self.decide(image([f"waterfall-wiki-tests:{sha}"]), completed=sha), "keep")
def test_stopped_container_reference_survives(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"]),
referenced=("sha256:example",)), "keep")
def test_all_tags_of_shared_retained_image_survive(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{A}", f"waterfall-wiki:{D}"])), "keep")
def test_foreign_and_legacy_tags_survive(self):
for tag in (f"prompt:{D}", f"other-app:{D}", "waterfall-wiki:local"):
self.assertEqual(self.decide(image([tag, f"waterfall-wiki:{D}"])), "keep")
def test_bootstrap_never_deletes_runtime(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"]), ready=False), "keep")
def test_failed_job_debug_window(self):
for repo in ("waterfall-wiki", "waterfall-wiki-tests"):
self.assertEqual(self.decide(image([f"{repo}:{D}"], created="2026-10-28T12:00:00Z")), "keep")
def test_successful_job_test_image_can_be_removed_immediately(self):
item = image([f"waterfall-wiki-tests:{D}"], created="2026-10-29T11:00:00Z")
self.assertEqual(self.decide(item, completed=D), "delete")
self.assertEqual(self.decide(item), "keep")
def test_expired_tests_dont_require_release_ledger(self):
self.assertEqual(self.decide(image([f"waterfall-wiki-tests:{D}"]), ready=False), "delete")
def test_only_owned_dangling_images_are_candidates(self):
self.assertEqual(self.decide(image()), "keep")
self.assertEqual(self.decide(image(labels={m.PROJECT: "other"})), "keep")
self.assertEqual(self.decide(image(labels={m.PROJECT: "waterfall-wiki"})), "delete")
def test_registry_digest_is_conservatively_retained(self):
item = image([f"waterfall-wiki:{D}"])
item["RepoDigests"] = ["registry.example/wiki@sha256:abc"]
self.assertEqual(self.decide(item), "keep")
def test_local_containerd_digest_does_not_disable_expiry(self):
item = image([f"waterfall-wiki-tests:{D}"])
item["RepoDigests"] = ["waterfall-wiki-tests@sha256:example"]
self.assertEqual(self.decide(item), "delete")
def test_failed_container_requires_ownership_age_and_no_mounts(self):
item = {"Config": {"Labels": {m.PROJECT: "waterfall-wiki", "io.entroia.wiki.build": "true"}},
"State": {"Status": "exited"}, "Mounts": [], "Created": "2026-10-20T12:00:00Z"}
self.assertTrue(m.container_expired(item, NOW))
for key, value in (("Mounts", [{"Type": "volume"}]),
("State", {"Status": "running"}),
("Created", "2026-10-29T11:00:00Z"),
("Config", {"Labels": {}})):
self.assertFalse(m.container_expired(dict(item, **{key: value}), NOW))
def test_failed_ci_preserves_original_exit_and_debug_images(self):
error = m.subprocess.CalledProcessError(7, ["docker", "build"])
with patch.object(m, "run", side_effect=error), patch.object(m, "cleanup") as cleanup:
self.assertEqual(m.ci(Path("/unused"), D), 7)
cleanup.assert_called_once_with(Path("/unused"), apply=True, completed_test=None)
def test_cleanup_error_does_not_turn_failed_ci_into_success(self):
error = m.subprocess.CalledProcessError(7, ["docker", "build"])
with patch.object(m, "run", side_effect=error), \
patch.object(m, "cleanup", side_effect=RuntimeError("busy")):
self.assertEqual(m.ci(Path("/unused"), D), 7)
def test_success_only_cleans_tests_after_deployment(self):
events = []
with patch.object(m, "run", side_effect=lambda args, **kw: events.append(args)), \
patch.object(m, "cleanup", side_effect=lambda *a, **kw: events.append(kw)):
self.assertEqual(m.ci(Path("/unused"), D), 0)
self.assertEqual(events[-2], ["bash", "scripts/deploy.sh", D])
self.assertEqual(events[-1], {"apply": True, "completed_test": D})
def test_busy_lock_refuses_work(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "lock"
with m.lock(path):
with self.assertRaisesRegex(RuntimeError, "Busy"):
with m.lock(path):
self.fail("Second holder entered lock")
def test_only_current_verified_sha_can_be_recorded_and_order_is_unique(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "current").mkdir()
(root / "current/release.env").write_text(f"RELEASE_SHA={A}\n")
(root / "successful-releases").write_text(f"{B}\n{A}\n{C}\n")
m.record(root, A)
self.assertEqual(m.history(root), [A, B, C])
with self.assertRaises(RuntimeError):
m.record(root, D)
(root / "successful-releases").write_text("bad-ledger\n")
with self.assertRaises(RuntimeError):
m.history(root)
def test_preview_has_no_docker_mutations(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", return_value=[]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m, "run") as run, patch.object(m.subprocess, "run") as mutate:
m.cleanup(root)
run.assert_not_called()
mutate.assert_not_called()
def test_cleanup_rechecks_container_reference_before_removal(self):
with tempfile.TemporaryDirectory() as directory:
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", side_effect=[[], [], [{"Image": item["Id"]}]]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m.subprocess, "run") as mutate:
m.cleanup(Path(directory), apply=True)
mutate.assert_not_called()
def test_cleanup_never_forces_or_removes_volumes(self):
with tempfile.TemporaryDirectory() as directory:
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", return_value=[]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m.subprocess, "run") as mutate:
mutate.return_value.returncode = 0
m.cleanup(Path(directory), apply=True)
mutate.assert_called_once_with(["docker", "image", "rm", f"waterfall-wiki-tests:{D}"], text=True)
if __name__ == "__main__":
unittest.main()