hd-garden 0.3.9 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/garden-post-review/SKILL.md +2 -0
- package/.agents/skills/garden-ui/SKILL.md +111 -0
- package/.env.example +8 -0
- package/app/admin/page.tsx +14 -3
- package/app/admin/preview/[...path]/page.tsx +88 -0
- package/app/admin/ui/blocks/page.tsx +16 -0
- package/app/admin/ui/catalog/page.tsx +16 -0
- package/app/admin/ui/evals/page.tsx +19 -0
- package/app/admin/ui/page.tsx +22 -0
- package/app/admin-ui.css +227 -0
- package/app/api/admin/distribution/route.ts +4 -2
- package/app/api/admin/posts/source/route.ts +21 -0
- package/app/api/admin/preview/asset/route.ts +32 -0
- package/app/api/admin/thoughts/route.ts +1 -1
- package/app/api/admin/ui/blocks/route.ts +19 -0
- package/app/api/admin/ui/draft/route.ts +30 -0
- package/app/api/auth/github/route.ts +40 -1
- package/app/components/AdminDashboard.tsx +53 -1
- package/app/components/AdminUiPanels.tsx +258 -0
- package/app/components/AdminUiShell.tsx +56 -0
- package/app/components/MarkdownArticle.tsx +15 -5
- package/app/components/UiBlock.tsx +22 -0
- package/app/components/UiStudio.tsx +422 -0
- package/app/components/ui-studio-keys.ts +3 -0
- package/app/globals.css +2 -0
- package/app/lib/admin-auth.ts +13 -1
- package/app/lib/content.ts +2 -1
- package/app/lib/github-content.ts +47 -4
- package/app/lib/repository-path.mjs +55 -0
- package/app/lib/ui-content.ts +79 -0
- package/app/lib/ui-model.ts +145 -0
- package/app/raw.d.ts +4 -0
- package/app/rss.xml/route.ts +2 -1
- package/app/ui-parity/page.tsx +120 -0
- package/bin/garden.mjs +71 -0
- package/eslint.config.mjs +3 -0
- package/package.json +15 -1
- package/scripts/build-content.mjs +35 -4
- package/scripts/build-ui-preview.mjs +29 -0
- package/scripts/check-post.mjs +26 -4
- package/scripts/ui-cli.mjs +303 -0
- package/tsconfig.json +1 -1
- package/ui/core/apply.mjs +81 -0
- package/ui/core/catalog.mjs +536 -0
- package/ui/core/compile.mjs +63 -0
- package/ui/core/demos.mjs +223 -0
- package/ui/core/expr.mjs +310 -0
- package/ui/core/fence.mjs +201 -0
- package/ui/core/fix-prompt.mjs +35 -0
- package/ui/core/format.mjs +96 -0
- package/ui/core/index.mjs +37 -0
- package/ui/core/parse.mjs +229 -0
- package/ui/core/scan.mjs +53 -0
- package/ui/core/sha256.mjs +66 -0
- package/ui/core/starters.mjs +18 -0
- package/ui/core/state.mjs +259 -0
- package/ui/core/static.mjs +524 -0
- package/ui/core/transform.mjs +146 -0
- package/ui/core/validate.mjs +665 -0
- package/ui/evals/cases.yaml +155 -0
- package/ui/preview/dist/garden-ui.css +1182 -0
- package/ui/preview/dist/garden-ui.esm.js +367 -0
- package/ui/preview/dist/garden-ui.iife.js +367 -0
- package/ui/preview/index.tsx +191 -0
- package/ui/preview/mermaid-shim.ts +19 -0
- package/ui/prompt/build.mjs +24 -0
- package/ui/prompt/recipes/code-walkthrough.md +68 -0
- package/ui/prompt/recipes/compare.md +48 -0
- package/ui/prompt/recipes/converter.md +55 -0
- package/ui/prompt/recipes/data-chart.md +57 -0
- package/ui/prompt/recipes/inspect-grid.md +45 -0
- package/ui/prompt/recipes/param-bench.md +55 -0
- package/ui/prompt/recipes/plan.md +45 -0
- package/ui/prompt/recipes/stepper.md +52 -0
- package/ui/prompt/recipes/trace.md +62 -0
- package/ui/prompt/rules.md +104 -0
- package/ui/prompt/studio.md +20 -0
- package/ui/react/CatalogPage.tsx +166 -0
- package/ui/react/components/chart.tsx +470 -0
- package/ui/react/components/display.tsx +296 -0
- package/ui/react/components/inputs.tsx +268 -0
- package/ui/react/components/layout.tsx +236 -0
- package/ui/react/components/media.tsx +184 -0
- package/ui/react/index.tsx +59 -0
- package/ui/react/recipes.ts +25 -0
- package/ui/react/runtime.tsx +239 -0
- package/ui/react/types.ts +29 -0
- package/ui/react/ui.css +1182 -0
- package/vite.config.ts +4 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { requireAdminApi } from "../../../../lib/admin-auth";
|
|
2
|
+
import { scanRepositoryBlocks } from "../../../../lib/ui-content";
|
|
3
|
+
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const user = await requireAdminApi();
|
|
6
|
+
if (user instanceof Response) return user;
|
|
7
|
+
try {
|
|
8
|
+
const files = await scanRepositoryBlocks();
|
|
9
|
+
const counts = { uncompiled: 0, stale: 0, error: 0, ok: 0 };
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
for (const block of file.blocks) {
|
|
12
|
+
if (block.lang !== "widget") counts[block.status as keyof typeof counts] += 1;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return Response.json({ files, counts });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return Response.json({ error: error instanceof Error ? error.message : "读取交互块失败。" }, { status: 503 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { requireAdminMutation } from "../../../../lib/admin-auth";
|
|
2
|
+
import { consumeDraft, draftUiBlock, modelConfig, remainingDrafts } from "../../../../lib/ui-model";
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request) {
|
|
5
|
+
const user = await requireAdminMutation(request);
|
|
6
|
+
if (user instanceof Response) return user;
|
|
7
|
+
if (!modelConfig()) {
|
|
8
|
+
return Response.json({ error: "后台还没接上模型:请配置 UI_MODEL_BASE_URL、UI_MODEL_API_KEY 和 UI_MODEL_NAME。" }, { status: 503 });
|
|
9
|
+
}
|
|
10
|
+
const payload = (await request.json().catch(() => null)) as { mode?: unknown; instruction?: unknown; fence?: unknown } | null;
|
|
11
|
+
const mode = payload?.mode === "revise" ? "revise" : payload?.mode === "create" ? "create" : null;
|
|
12
|
+
const instruction = typeof payload?.instruction === "string" ? payload.instruction.trim() : "";
|
|
13
|
+
const fence = typeof payload?.fence === "string" ? payload.fence : undefined;
|
|
14
|
+
if (!mode || !instruction || instruction.length > 4000 || (fence && fence.length > 20000)) {
|
|
15
|
+
return Response.json({ error: "请求不完整:需要 mode 和不超过 4000 字的指令。" }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
if (mode === "revise" && !fence) return Response.json({ error: "修改需要当前围栏。" }, { status: 400 });
|
|
18
|
+
if (!consumeDraft(user.login)) {
|
|
19
|
+
return Response.json({ error: "这一小时的生成次数用完了,稍后再试。", remaining: 0 }, { status: 429 });
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const result = await draftUiBlock({ mode, instruction, fence });
|
|
23
|
+
return Response.json({ ...result, remaining: remainingDrafts(user.login) });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
return Response.json(
|
|
26
|
+
{ error: error instanceof Error ? error.message : "模型调用失败。", remaining: remainingDrafts(user.login) },
|
|
27
|
+
{ status: 502 },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createAdminSession,
|
|
3
|
+
isGitHubDevLoginAvailable,
|
|
4
|
+
isGitHubLoginAllowed,
|
|
5
|
+
isGitHubOAuthConfigured,
|
|
6
|
+
sessionCookie,
|
|
7
|
+
} from "../../../lib/admin-auth";
|
|
2
8
|
|
|
3
9
|
const STATE_COOKIE = "hedon_github_oauth_state";
|
|
4
10
|
|
|
@@ -6,7 +12,40 @@ function redirectUri(request: Request) {
|
|
|
6
12
|
return process.env.GITHUB_OAUTH_REDIRECT_URI ?? `${new URL(request.url).origin}/api/auth/github/callback`;
|
|
7
13
|
}
|
|
8
14
|
|
|
15
|
+
async function devGitHubUser() {
|
|
16
|
+
const token = process.env.GITHUB_DEV_TOKEN;
|
|
17
|
+
if (!token) return null;
|
|
18
|
+
const response = await fetch("https://api.github.com/user", {
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: "application/vnd.github+json",
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
"User-Agent": "garden-dev",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) return null;
|
|
26
|
+
const profile = (await response.json()) as { login?: string; avatar_url?: string };
|
|
27
|
+
if (!profile.login || !isGitHubLoginAllowed(profile.login)) return null;
|
|
28
|
+
return { login: profile.login, avatarUrl: profile.avatar_url ?? "" };
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export async function GET(request: Request) {
|
|
32
|
+
if (!isGitHubOAuthConfigured() && isGitHubDevLoginAvailable()) {
|
|
33
|
+
const user = await devGitHubUser();
|
|
34
|
+
if (!user) {
|
|
35
|
+
return Response.json({ error: "本机 GitHub 登录不可用。" }, { status: 503 });
|
|
36
|
+
}
|
|
37
|
+
const session = await createAdminSession(user);
|
|
38
|
+
if (!session) {
|
|
39
|
+
return Response.json({ error: "本机 GitHub 登录不可用。" }, { status: 503 });
|
|
40
|
+
}
|
|
41
|
+
return new Response(null, {
|
|
42
|
+
status: 302,
|
|
43
|
+
headers: {
|
|
44
|
+
Location: new URL("/admin", request.url).toString(),
|
|
45
|
+
"Set-Cookie": sessionCookie(session),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
10
49
|
if (!isGitHubOAuthConfigured()) {
|
|
11
50
|
return Response.json(
|
|
12
51
|
{ error: "GitHub OAuth 尚未配置。" },
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { formatAuthorDate } from "../../scripts/site-date.mjs";
|
|
5
|
-
import { useMemo, useState } from "react";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
6
|
import {
|
|
7
7
|
ArrowSquareOut,
|
|
8
8
|
ArrowUpRight,
|
|
@@ -124,6 +124,54 @@ function MediaFields({
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
type UiOverview = {
|
|
128
|
+
modelName: string | null;
|
|
129
|
+
remaining: number;
|
|
130
|
+
engineVersion: string;
|
|
131
|
+
catalogVersion: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type BlockCounts = { uncompiled: number; stale: number; error: number; ok: number };
|
|
135
|
+
|
|
136
|
+
function IntelligentUiCard({ ui }: { ui: UiOverview }) {
|
|
137
|
+
const [counts, setCounts] = useState<BlockCounts | null>(null);
|
|
138
|
+
const [failed, setFailed] = useState(false);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
let active = true;
|
|
141
|
+
fetch("/api/admin/ui/blocks")
|
|
142
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error("blocks"))))
|
|
143
|
+
.then((body: { counts: BlockCounts }) => {
|
|
144
|
+
if (active) setCounts(body.counts);
|
|
145
|
+
})
|
|
146
|
+
.catch(() => {
|
|
147
|
+
if (active) setFailed(true);
|
|
148
|
+
});
|
|
149
|
+
return () => {
|
|
150
|
+
active = false;
|
|
151
|
+
};
|
|
152
|
+
}, []);
|
|
153
|
+
return (
|
|
154
|
+
<section className="admin-panel admin-ui-card">
|
|
155
|
+
<div className="admin-panel-heading">
|
|
156
|
+
<div>
|
|
157
|
+
<span>Intelligent UI</span>
|
|
158
|
+
<h2>交互块</h2>
|
|
159
|
+
</div>
|
|
160
|
+
<Link href="/admin/ui">打开工作台</Link>
|
|
161
|
+
</div>
|
|
162
|
+
<p className="ui-studio-meta">
|
|
163
|
+
{ui.modelName ? ui.modelName : "模型未连接"}
|
|
164
|
+
{" · "}
|
|
165
|
+
{counts
|
|
166
|
+
? <Link href="/admin/ui/blocks">{`未编译 ${counts.uncompiled} · 过期 ${counts.stale} · 有错 ${counts.error} · 正常 ${counts.ok}`}</Link>
|
|
167
|
+
: failed ? "交互块读取失败" : "正在统计交互块…"}
|
|
168
|
+
{" · "}
|
|
169
|
+
<Link href="/admin/ui/catalog">组件清单</Link>
|
|
170
|
+
</p>
|
|
171
|
+
</section>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
export function AdminDashboard({
|
|
128
176
|
userName,
|
|
129
177
|
initialThoughts,
|
|
@@ -131,6 +179,7 @@ export function AdminDashboard({
|
|
|
131
179
|
catalog,
|
|
132
180
|
publicSiteUrl,
|
|
133
181
|
contentWarning,
|
|
182
|
+
ui,
|
|
134
183
|
}: {
|
|
135
184
|
userName: string;
|
|
136
185
|
initialThoughts: ManagedThought[];
|
|
@@ -138,6 +187,7 @@ export function AdminDashboard({
|
|
|
138
187
|
catalog: CatalogEntry[];
|
|
139
188
|
publicSiteUrl: string;
|
|
140
189
|
contentWarning: string;
|
|
190
|
+
ui: UiOverview;
|
|
141
191
|
}) {
|
|
142
192
|
const [thoughts, setThoughts] = useState(initialThoughts);
|
|
143
193
|
const [title, setTitle] = useState("");
|
|
@@ -342,6 +392,8 @@ export function AdminDashboard({
|
|
|
342
392
|
</dl>
|
|
343
393
|
</section>
|
|
344
394
|
|
|
395
|
+
<IntelligentUiCard ui={ui} />
|
|
396
|
+
|
|
345
397
|
{notice && <p className="admin-notice"><CheckCircle size={16} /> {notice}</p>}
|
|
346
398
|
{contentWarning && <p className="admin-notice"><CheckCircle size={16} /> {contentWarning}</p>}
|
|
347
399
|
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { buildFixPrompt } from "../../ui/core/fix-prompt.mjs";
|
|
6
|
+
import { CatalogPage, type RecipeSource } from "../../ui/react/CatalogPage";
|
|
7
|
+
import { EVALS_STORAGE_KEY as EVALS_KEY, STUDIO_START_KEY } from "./ui-studio-keys";
|
|
8
|
+
|
|
9
|
+
export function AdminCatalog({ recipes }: { recipes: RecipeSource[] }) {
|
|
10
|
+
return (
|
|
11
|
+
<section className="admin-panel ui-admin-catalog">
|
|
12
|
+
<CatalogPage
|
|
13
|
+
recipes={recipes}
|
|
14
|
+
onStart={(fence) => {
|
|
15
|
+
window.localStorage.setItem(STUDIO_START_KEY, fence);
|
|
16
|
+
window.location.assign("/admin/ui");
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
</section>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Block = {
|
|
24
|
+
lang: string;
|
|
25
|
+
id: string | null;
|
|
26
|
+
status: "uncompiled" | "stale" | "error" | "ok";
|
|
27
|
+
startLine: number;
|
|
28
|
+
endLine: number;
|
|
29
|
+
hash: string;
|
|
30
|
+
intentHash: string | null;
|
|
31
|
+
intent: string | null;
|
|
32
|
+
caption: string | null;
|
|
33
|
+
section: string | null;
|
|
34
|
+
diagnostics: Array<{ code: string; line: number; message: string; severity: string }>;
|
|
35
|
+
raw: string;
|
|
36
|
+
};
|
|
37
|
+
type FileBlocks = { path: string; title: string | null; draft: boolean; blocks: Block[] };
|
|
38
|
+
|
|
39
|
+
const STATUS_LABELS: Record<Block["status"], string> = { uncompiled: "未编译", stale: "过期", error: "有错", ok: "正常" };
|
|
40
|
+
|
|
41
|
+
const publicPath = (path: string) => {
|
|
42
|
+
const posts = /^content\/posts\/(.+)\.md$/u.exec(path);
|
|
43
|
+
if (posts) return `/blog/${posts[1]}`;
|
|
44
|
+
const thoughts = /^content\/thoughts\/(.+)\.md$/u.exec(path);
|
|
45
|
+
return thoughts ? `/thoughts/${thoughts[1]}` : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function AdminBlocks({ siteUrl }: { siteUrl: string }) {
|
|
49
|
+
const [files, setFiles] = useState<FileBlocks[] | null>(null);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [filter, setFilter] = useState<"todo" | "all" | Block["status"]>("todo");
|
|
52
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let active = true;
|
|
56
|
+
fetch("/api/admin/ui/blocks")
|
|
57
|
+
.then((response) => response.json().then((body) => ({ ok: response.ok, body })))
|
|
58
|
+
.then(({ ok, body }) => {
|
|
59
|
+
if (!active) return;
|
|
60
|
+
if (ok) setFiles(body.files);
|
|
61
|
+
else setError(body.error ?? "读取失败。");
|
|
62
|
+
})
|
|
63
|
+
.catch(() => {
|
|
64
|
+
if (active) setError("读取失败。");
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
active = false;
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const visible = (block: Block) =>
|
|
72
|
+
block.lang !== "widget" && (filter === "all" || (filter === "todo" ? block.status !== "ok" : block.status === filter));
|
|
73
|
+
|
|
74
|
+
const copyPrompt = async (file: FileBlocks, block: Block) => {
|
|
75
|
+
const prompt = buildFixPrompt([{ file: file.path, block, diagnostics: block.diagnostics.filter((item) => item.severity === "error") }]);
|
|
76
|
+
await navigator.clipboard.writeText(prompt);
|
|
77
|
+
setCopied(`${file.path}:${block.startLine}`);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const counts = { uncompiled: 0, stale: 0, error: 0, ok: 0 };
|
|
81
|
+
for (const file of files ?? []) for (const block of file.blocks) if (block.lang !== "widget") counts[block.status] += 1;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<section className="admin-panel ui-admin-blocks">
|
|
85
|
+
<div className="admin-panel-heading">
|
|
86
|
+
<div><span>交互块</span><h2>所有稿件里的 intent 和 ui 块</h2></div>
|
|
87
|
+
</div>
|
|
88
|
+
<p className="ui-studio-meta">看到的是仓库里已推送的内容(含草稿);本地没推的改动看不到。</p>
|
|
89
|
+
<div className="ui-studio-switch" role="group" aria-label="按状态筛选">
|
|
90
|
+
{(["todo", "uncompiled", "stale", "error", "ok", "all"] as const).map((value) => (
|
|
91
|
+
<button key={value} type="button" aria-pressed={filter === value} onClick={() => setFilter(value)}>
|
|
92
|
+
{value === "todo" ? `需要处理 ${counts.uncompiled + counts.stale + counts.error}` : value === "all" ? "全部" : `${STATUS_LABELS[value]} ${counts[value]}`}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
{error ? <p className="ui-studio-message ui-studio-message-error">{error}</p> : null}
|
|
97
|
+
{!files && !error ? <p className="ui-studio-meta">正在扫描仓库…</p> : null}
|
|
98
|
+
{files?.map((file) => {
|
|
99
|
+
const blocks = file.blocks.filter(visible);
|
|
100
|
+
if (!blocks.length) return null;
|
|
101
|
+
const preview = `/admin/preview/${file.path.replace(/^content\//u, "").replace(/\.md$/u, "")}`;
|
|
102
|
+
const live = !file.draft ? publicPath(file.path) : null;
|
|
103
|
+
return (
|
|
104
|
+
<article key={file.path} className="ui-admin-file">
|
|
105
|
+
<h3>{file.title ?? file.path}{file.draft ? <span className="admin-status draft">草稿</span> : null}</h3>
|
|
106
|
+
<p className="ui-studio-meta">{file.path}</p>
|
|
107
|
+
<table className="ui-admin-table">
|
|
108
|
+
<thead>
|
|
109
|
+
<tr><th>块</th><th>状态</th><th>说明</th><th>操作</th></tr>
|
|
110
|
+
</thead>
|
|
111
|
+
<tbody>
|
|
112
|
+
{blocks.map((block) => (
|
|
113
|
+
<tr key={block.startLine}>
|
|
114
|
+
<td>{block.lang === "intent" ? "想法" : "ui"} · {block.id ?? `第 ${block.startLine} 行`}</td>
|
|
115
|
+
<td><span className={`ui-admin-status ui-admin-status-${block.status}`}>{STATUS_LABELS[block.status]}</span></td>
|
|
116
|
+
<td>{block.caption ?? block.intent?.split("\n")[0] ?? ""}{block.diagnostics.length ? `(${block.diagnostics.length} 条诊断)` : ""}</td>
|
|
117
|
+
<td className="ui-admin-actions">
|
|
118
|
+
<Link href={`/admin/ui?post=${encodeURIComponent(file.path)}&line=${block.startLine}`}>在工作台打开</Link>
|
|
119
|
+
{block.status !== "ok" ? (
|
|
120
|
+
<button type="button" onClick={() => copyPrompt(file, block)}>
|
|
121
|
+
{copied === `${file.path}:${block.startLine}` ? "已复制" : "复制修复提示词"}
|
|
122
|
+
</button>
|
|
123
|
+
) : null}
|
|
124
|
+
<Link href={`${preview}${block.id ? `#ui-${block.id}` : ""}`}>草稿预览</Link>
|
|
125
|
+
{live ? <a href={`${siteUrl}${live}${block.id ? `#ui-${block.id}` : ""}`} target="_blank" rel="noreferrer">公开页面</a> : null}
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
))}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</article>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</section>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type EvalCase = { id: string; intent: string; expect: { components?: string[]; assert?: boolean; note?: boolean } };
|
|
139
|
+
type EvalResult = {
|
|
140
|
+
id: string;
|
|
141
|
+
ok: boolean;
|
|
142
|
+
rounds: number;
|
|
143
|
+
components: boolean;
|
|
144
|
+
assert: boolean;
|
|
145
|
+
note: boolean;
|
|
146
|
+
tokens: number;
|
|
147
|
+
ms: number;
|
|
148
|
+
error?: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function summarize(results: EvalResult[]) {
|
|
152
|
+
const total = results.length || 1;
|
|
153
|
+
return {
|
|
154
|
+
pass: results.filter((item) => item.ok).length,
|
|
155
|
+
total: results.length,
|
|
156
|
+
rate: Math.round((results.filter((item) => item.ok).length / total) * 100),
|
|
157
|
+
rounds: (results.reduce((sum, item) => sum + item.rounds, 0) / total).toFixed(2),
|
|
158
|
+
tokens: results.reduce((sum, item) => sum + item.tokens, 0),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function AdminEvals({ cases, configured }: { cases: EvalCase[]; configured: boolean }) {
|
|
163
|
+
const [results, setResults] = useState<EvalResult[]>([]);
|
|
164
|
+
const [running, setRunning] = useState(false);
|
|
165
|
+
const mounted = useSyncExternalStore(() => () => {}, () => true, () => false);
|
|
166
|
+
// The baseline is the run saved before this page opened; only shown after mount to keep hydration stable.
|
|
167
|
+
const [baselineRaw] = useState(() => (typeof window === "undefined" ? null : window.localStorage.getItem(EVALS_KEY)));
|
|
168
|
+
const previous = useMemo<EvalResult[] | null>(() => {
|
|
169
|
+
if (!mounted) return null;
|
|
170
|
+
try {
|
|
171
|
+
return baselineRaw ? JSON.parse(baselineRaw) : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}, [baselineRaw, mounted]);
|
|
176
|
+
|
|
177
|
+
async function run() {
|
|
178
|
+
setRunning(true);
|
|
179
|
+
const collected: EvalResult[] = [];
|
|
180
|
+
setResults([]);
|
|
181
|
+
for (const item of cases) {
|
|
182
|
+
const started = performance.now();
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch("/api/admin/ui/draft", {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ mode: "create", instruction: item.intent }),
|
|
188
|
+
});
|
|
189
|
+
const body = (await response.json()) as { fence?: string | null; note?: string | null; rounds?: number; diagnostics?: Array<{ severity: string }>; usage?: { total_tokens?: number }; error?: string };
|
|
190
|
+
const fence = body.fence ?? "";
|
|
191
|
+
const used = new Set([...fence.matchAll(/<([A-Z][A-Za-z]*)\b/gu)].map((match) => match[1]));
|
|
192
|
+
const valid = response.ok && Boolean(body.fence) && !(body.diagnostics ?? []).some((entry) => entry.severity === "error");
|
|
193
|
+
const components = (item.expect.components ?? []).every((name) => used.has(name));
|
|
194
|
+
const assert = !item.expect.assert || /\nexpect:/u.test(fence);
|
|
195
|
+
const note = !item.expect.note || Boolean(body.note);
|
|
196
|
+
collected.push({
|
|
197
|
+
id: item.id,
|
|
198
|
+
ok: valid && components && assert && note,
|
|
199
|
+
rounds: body.rounds ?? 0,
|
|
200
|
+
components,
|
|
201
|
+
assert,
|
|
202
|
+
note,
|
|
203
|
+
tokens: body.usage?.total_tokens ?? 0,
|
|
204
|
+
ms: Math.round(performance.now() - started),
|
|
205
|
+
error: response.ok ? undefined : body.error,
|
|
206
|
+
});
|
|
207
|
+
} catch (error) {
|
|
208
|
+
collected.push({ id: item.id, ok: false, rounds: 0, components: false, assert: false, note: false, tokens: 0, ms: Math.round(performance.now() - started), error: error instanceof Error ? error.message : "失败" });
|
|
209
|
+
}
|
|
210
|
+
setResults([...collected]);
|
|
211
|
+
}
|
|
212
|
+
window.localStorage.setItem(EVALS_KEY, JSON.stringify(collected));
|
|
213
|
+
setRunning(false);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const current = summarize(results);
|
|
217
|
+
const before = previous ? summarize(previous) : null;
|
|
218
|
+
return (
|
|
219
|
+
<section className="admin-panel ui-admin-evals">
|
|
220
|
+
<div className="admin-panel-heading">
|
|
221
|
+
<div><span>模型自检</span><h2>用 {cases.length} 段原话给当前模型和提示词打分</h2></div>
|
|
222
|
+
</div>
|
|
223
|
+
<p className="ui-studio-meta">逐条调用工作台的生成接口,共 {cases.length} 次,计入每小时限额。换模型或改提示词后跑一次。</p>
|
|
224
|
+
<div className="admin-actions">
|
|
225
|
+
<button type="button" className="admin-primary-button" disabled={!configured || running} onClick={run}>{running ? `正在跑 ${results.length}/${cases.length}…` : "开始自检"}</button>
|
|
226
|
+
</div>
|
|
227
|
+
{!configured ? <p className="ui-studio-message ui-studio-message-error">后台还没接上模型。</p> : null}
|
|
228
|
+
{results.length ? (
|
|
229
|
+
<p className="ui-studio-meta">
|
|
230
|
+
本次:通过 {current.pass}/{current.total}({current.rate}%),平均修复 {current.rounds} 轮,共 {current.tokens} token。
|
|
231
|
+
{before ? ` 上次:通过 ${before.pass}/${before.total}(${before.rate}%),平均修复 ${before.rounds} 轮,共 ${before.tokens} token。` : ""}
|
|
232
|
+
</p>
|
|
233
|
+
) : before ? <p className="ui-studio-meta">上次:通过 {before.pass}/{before.total}({before.rate}%),平均修复 {before.rounds} 轮,共 {before.tokens} token。</p> : null}
|
|
234
|
+
<table className="ui-admin-table">
|
|
235
|
+
<thead>
|
|
236
|
+
<tr><th>样例</th><th>结果</th><th>修复轮数</th><th>组件</th><th>expect</th><th>提醒</th><th>token</th><th>耗时</th></tr>
|
|
237
|
+
</thead>
|
|
238
|
+
<tbody>
|
|
239
|
+
{cases.map((item) => {
|
|
240
|
+
const result = results.find((entry) => entry.id === item.id);
|
|
241
|
+
return (
|
|
242
|
+
<tr key={item.id}>
|
|
243
|
+
<td title={item.intent}>{item.id}</td>
|
|
244
|
+
<td>{result ? (result.ok ? "通过" : result.error ?? "未通过") : "—"}</td>
|
|
245
|
+
<td>{result ? result.rounds : "—"}</td>
|
|
246
|
+
<td>{result ? (result.components ? "✓" : `缺 ${(item.expect.components ?? []).join("、")}`) : (item.expect.components ?? []).join("、")}</td>
|
|
247
|
+
<td>{item.expect.assert ? (result ? (result.assert ? "✓" : "缺") : "要") : "—"}</td>
|
|
248
|
+
<td>{item.expect.note ? (result ? (result.note ? "✓" : "缺") : "要") : "—"}</td>
|
|
249
|
+
<td>{result ? result.tokens : "—"}</td>
|
|
250
|
+
<td>{result ? `${(result.ms / 1000).toFixed(1)}s` : "—"}</td>
|
|
251
|
+
</tr>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</tbody>
|
|
255
|
+
</table>
|
|
256
|
+
</section>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { getAdminUser } from "../lib/admin-auth";
|
|
5
|
+
|
|
6
|
+
const SECTIONS = [
|
|
7
|
+
{ href: "/admin", label: "总览" },
|
|
8
|
+
{ href: "/admin/ui", label: "组件工作台" },
|
|
9
|
+
{ href: "/admin/ui/catalog", label: "组件清单" },
|
|
10
|
+
{ href: "/admin/ui/blocks", label: "交互块" },
|
|
11
|
+
{ href: "/admin/ui/evals", label: "模型自检" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const isStaticExport = () => process.env.STATIC_EXPORT === "1";
|
|
15
|
+
|
|
16
|
+
export async function requireAdminPage() {
|
|
17
|
+
const user = await getAdminUser();
|
|
18
|
+
if (!user) redirect("/admin");
|
|
19
|
+
return user;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function StaticAdminNotice() {
|
|
23
|
+
return (
|
|
24
|
+
<main className="admin-access-page">
|
|
25
|
+
<div className="admin-access-card">
|
|
26
|
+
<h1>内容后台独立运行。</h1>
|
|
27
|
+
<p>公开博客由 GitHub Pages 静态托管;这一页只在独立后台里可用。</p>
|
|
28
|
+
<Link href="/">返回公开博客</Link>
|
|
29
|
+
</div>
|
|
30
|
+
</main>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AdminUiShell({ active, userName, children }: { active: string; userName: string; children: ReactNode }) {
|
|
35
|
+
return (
|
|
36
|
+
<main className="admin-shell admin-ui-shell">
|
|
37
|
+
<header className="admin-topbar">
|
|
38
|
+
<Link className="admin-brand" href="/admin">HEDON <span>STUDIO</span></Link>
|
|
39
|
+
<div>
|
|
40
|
+
<span>{userName}</span>
|
|
41
|
+
<form action="/api/auth/logout" method="post">
|
|
42
|
+
<button type="submit" className="admin-signout">退出</button>
|
|
43
|
+
</form>
|
|
44
|
+
</div>
|
|
45
|
+
</header>
|
|
46
|
+
<nav className="admin-ui-nav" aria-label="后台分区">
|
|
47
|
+
{SECTIONS.map((section) => (
|
|
48
|
+
<Link key={section.href} href={section.href} aria-current={section.href === active ? "page" : undefined}>
|
|
49
|
+
{section.label}
|
|
50
|
+
</Link>
|
|
51
|
+
))}
|
|
52
|
+
</nav>
|
|
53
|
+
{children}
|
|
54
|
+
</main>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -14,6 +14,11 @@ import { MermaidDiagram } from "./MermaidDiagram";
|
|
|
14
14
|
import { MarkdownImage } from "./MarkdownImage";
|
|
15
15
|
import { ExternalEmbed } from "./ExternalEmbed";
|
|
16
16
|
import { WidgetChart } from "./WidgetChart";
|
|
17
|
+
import { IntentBlock, UiBlock, UiErrorBlock } from "./UiBlock";
|
|
18
|
+
|
|
19
|
+
const BLOCK_LANGUAGES = ["language-mermaid", "language-embed", "language-widget", "language-ui", "language-ui-error", "language-intent"];
|
|
20
|
+
const isBlockLanguage = (className?: string) =>
|
|
21
|
+
Boolean(className) && BLOCK_LANGUAGES.some((language) => className?.split(/\s+/u).includes(language));
|
|
17
22
|
|
|
18
23
|
function CodeBlock({
|
|
19
24
|
className,
|
|
@@ -29,6 +34,15 @@ function CodeBlock({
|
|
|
29
34
|
if (className?.includes("language-widget")) {
|
|
30
35
|
return <WidgetChart source={String(children).trim()} />;
|
|
31
36
|
}
|
|
37
|
+
if (className?.split(/\s+/u).includes("language-ui")) {
|
|
38
|
+
return <UiBlock source={String(children)} />;
|
|
39
|
+
}
|
|
40
|
+
if (className?.split(/\s+/u).includes("language-ui-error")) {
|
|
41
|
+
return <UiErrorBlock source={String(children)} />;
|
|
42
|
+
}
|
|
43
|
+
if (className?.split(/\s+/u).includes("language-intent")) {
|
|
44
|
+
return <IntentBlock source={String(children)} />;
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
const codeProps = { ...props };
|
|
34
48
|
delete codeProps.node;
|
|
@@ -118,11 +132,7 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
118
132
|
const preProps = { ...props };
|
|
119
133
|
delete preProps.node;
|
|
120
134
|
|
|
121
|
-
if (
|
|
122
|
-
className?.includes("language-mermaid") ||
|
|
123
|
-
className?.includes("language-embed") ||
|
|
124
|
-
className?.includes("language-widget")
|
|
125
|
-
) {
|
|
135
|
+
if (isBlockLanguage(className)) {
|
|
126
136
|
return <>{children}</>;
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readPayload } from "../../ui/core/compile.mjs";
|
|
2
|
+
import { IntentCard, UiErrorCard, UiFigure, type Diagnostic, type UiPayload } from "../../ui/react";
|
|
3
|
+
|
|
4
|
+
export function UiBlock({ source }: { source: string }) {
|
|
5
|
+
const payload = readPayload(source) as UiPayload | null;
|
|
6
|
+
if (!payload) return <p className="external-embed-error">这块交互的数据损坏了,请重新运行 garden sync。</p>;
|
|
7
|
+
return <UiFigure payload={payload} />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function UiErrorBlock({ source }: { source: string }) {
|
|
11
|
+
let value: { id?: string | null; caption?: string; diagnostics?: Diagnostic[] } = {};
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(source);
|
|
14
|
+
} catch {
|
|
15
|
+
value = {};
|
|
16
|
+
}
|
|
17
|
+
return <UiErrorCard id={value.id} caption={value.caption ?? ""} diagnostics={value.diagnostics ?? []} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function IntentBlock({ source, id }: { source: string; id?: string | null }) {
|
|
21
|
+
return <IntentCard text={source.trim()} id={id} />;
|
|
22
|
+
}
|