diff --git a/apps/waterfall-wiki/src/components/DocumentDirectory.tsx b/apps/waterfall-wiki/src/components/DocumentDirectory.tsx new file mode 100644 index 0000000..faea2a7 --- /dev/null +++ b/apps/waterfall-wiki/src/components/DocumentDirectory.tsx @@ -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 ( + + + + ); +} +function DirectoryContent({ + communityOnly, + mine, +}: { + communityOnly: boolean; + mine: boolean; +}) { + const router = useRouter(), + account = useGuidanceAccount(); + const [published, setPublished] = useState([]), + [loading, setLoading] = useState(true), + [error, setError] = useState(""); + const [q, setQ] = useState(""), + [kind, setKind] = useState("all"), + [source, setSource] = useState(communityOnly ? "published" : "all"), + [sort, setSort] = useState("default"); + const controller = useRef(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) => ({ + ...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 = {}; + 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 ( + <> + + + + Agent 入口 + + + + {!communityOnly && ( + <> +
+ + + +
+

好用的知识,从不必整套加载。

+

+ 每篇文档都有适用条件和固定版本。需要更多时,再沿着有用的链接继续阅读。 +

+
+ + + 了解工作原理 + + +
+
+ 试试这些任务 + {["代码审查", "规划", "验证"].map((t) => ( + + ))} +
+ + )} + {mine && !account.loading && !account.user ? ( +
+ {account.error && ( +
+ + {account.error}{" "} + + +
+ )} + + + 登录 / 创建账户 + + + +
+ ) : ( + <> +
+ ({ + 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 }); + }} + /> +
+
+ + setQ(e.target.value)} + /> + {q && ( + + )} + + +
+ {!communityOnly && ( + + )} + +
+
+
+

+ {waiting + ? "正在加载文档…" + : error + ? `当前显示 ${documents.length} 篇,社区目录加载失败` + : `找到 ${documents.length} 篇文档`} +

+ {filtered ? ( + + ) : ( + 最多展示最近 100 篇社区文档 + )} +
+ {error && ( +
+ + {error}{" "} + + +
+ )} + + {waiting && (!documents.length || communityOnly) ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : ( + documents.map((d) => ( + +
+ + + +
+

{d.title}

+

{d.summary}

+

+ {d.id} +

+
+ + v{d.revision} + + {d.source === "example" ? "协议样例" : "社区发布"} + +
+
+
+ + + + v{d.revision} + + {d.source === "example" ? "协议样例" : "社区发布"} + + + + )) + )} + {!waiting && !documents.length && !error && ( + + {filtered ? ( + + ) : ( + + + 发布一篇文档 + + )} + + )} +
+
+ + {mine + ? "只有你可以更新自己的文档,已发布的历史版本保持不变。" + : "社区文档与协议样例分别标识,采用前请确认适用条件与版本。"} + + + 了解读取规范 + + +
+ + )} + + ); +} diff --git a/apps/waterfall-wiki/src/components/GuidanceReader.tsx b/apps/waterfall-wiki/src/components/GuidanceReader.tsx new file mode 100644 index 0000000..3e6eaf2 --- /dev/null +++ b/apps/waterfall-wiki/src/components/GuidanceReader.tsx @@ -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(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 ( + +
+ + + {source === "example" ? "返回探索文档" : "返回社区文档"} + +
+ + + {source === "example" ? "协议样例" : "社区发布"} + + v{doc.revision} + {isWithdrawn && ( + 已撤下 · 历史版本 + )} +
+

{doc.title}

+

{doc.summary}

+
+ + + 公开文档 + + + + 固定版本 {doc.revision} + + {current?.createdAt && ( + + + {dateLabel(current.createdAt)} + + )} +
+
+ window.location.origin + api} + /> + + + 读取规范 XML + + + {canEdit && !isWithdrawn && ( + + + 编辑最新版本 + + )} +
+
+ {isWithdrawn && ( + + 这篇文档已从公开目录撤下。你正在阅读固定历史版本,已有引用仍可继续使用。 + + )} +
+
+ + {tab === "overview" ? ( +
+
+

+ + 何时使用这篇文档 +

+

{doc.applies}

+
+
+

+ + 指导正文 +

+
{doc.body}
+
+ {!!doc.steps.length && ( +
+

+ + 建议步骤 +

+
    + {doc.steps.map((step, index) => ( +
  1. {step}
  2. + ))} +
+
+ )} + {!!doc.checks.length && ( +
+

+ + 完成检查 +

+
    + {doc.checks.map((check, index) => ( +
  • {check}
  • + ))} +
+
+ )} + +
+

本篇目录

+ +
+
+

+ 采用这一篇 +

+

+ 把固定版本的读取地址交给 Agent。关联文档是候选帮助,无需一起加载。 +

+ {api} + window.location.origin + api} + /> +
+ {!!backlinks.length && ( +
+

被这些文档引用

+
+ {backlinks.map((b) => ( + + {b.title} + + + ))} +
+
+ )} + +
+ +
+

撤下这篇文档?

+

+ 它将不再出现在目录中。已发布的固定历史版本会保留,已有引用仍然可以访问。 +

+ {error && {error}} +
+
+ + +
+
+
+ ); +} diff --git a/apps/waterfall-wiki/src/components/GuidanceShell.tsx b/apps/waterfall-wiki/src/components/GuidanceShell.tsx index bcc5985..8eb3de9 100644 --- a/apps/waterfall-wiki/src/components/GuidanceShell.tsx +++ b/apps/waterfall-wiki/src/components/GuidanceShell.tsx @@ -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
↗ 织页 实验版登录 / 注册 ↗
{children}
织页 · Linked Guidance · 名称与协议仍在探索中让每一页都能成为起点 ↗
} +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; +}; +const AccountContext = createContext({ + 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(null); + const [user, setUser] = useState(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 ( + <> + dialog.current?.close()} + > + + + + + 织页KNOWLEDGE FOR AGENTS + + +
+ + 公共知识空间 + Public +
+

工作空间

+ +

资源

+ +
+
+ 从一篇文档开始 +

选择适用的指导,需要更多背景时,再沿链接继续阅读。

+ + 了解使用方法 + +
+ + + {user ? ( + user.email.slice(0, 1).toUpperCase() + ) : ( + + )} + + + + {user ? user.email : loading ? "正在检查账户…" : "登录你的账户"} + + + {user ? "管理与发布你的文档" : "邮箱登录 · 开始发布"} + + + + +
+ + ); + } + return ( + +
+ + {`${title} · 织页`} + + + + 跳到正文 + + + + + {navigation(true)} + +
+
+
+ + 工作空间 + + {title} +
+
+ + + Linked Guidance 0.1 + + {router.pathname !== "/write" && ( + + + 创建文档 + + )} + {!user && !loading && ( + + + 登录 + + )} +
+
+
+ {children} +
+
+ 织页 · 让知识按需连接 + + 文档协议 + +
+
+
+
+ ); +} diff --git a/apps/waterfall-wiki/src/components/GuidanceUI.tsx b/apps/waterfall-wiki/src/components/GuidanceUI.tsx new file mode 100644 index 0000000..b5bc676 --- /dev/null +++ b/apps/waterfall-wiki/src/components/GuidanceUI.tsx @@ -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 = { + guide: "操作指导", + context: "背景知识", + evidence: "事实依据", +}; +export function KindIcon({ + kind, + size = 18, +}: { + kind: Guidance["kind"]; + size?: number; +}) { + const Icon = + kind === "guide" ? FileText : kind === "context" ? BookOpen : FileCheck2; + return