Migrate AI Hint feed generation

Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients.
This commit is contained in:
Rocky
2026-08-21 15:17:15 +08:00
parent d0abe27623
commit 454ba8ddc5
40 changed files with 2838 additions and 10 deletions
+25
View File
@@ -4,6 +4,9 @@ import type {
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminHintPack,
HintFeedGenerationResponse,
HintFeedGenerationStatus,
HintFeedSettings,
AdminLoginResponse,
AuditQuery,
AuditLogEntry,
@@ -25,6 +28,7 @@ import type {
UsersQuery,
UserSummary,
UpdateHintPackRequest,
UpdateHintFeedSettingsRequest,
UpdateOfficialSkillRequest,
} from "./types";
@@ -82,6 +86,9 @@ function safeMessage(status: number, code?: string): string {
CONTENT_SKILL_NOT_FOUND: "未找到该官方 Skill",
CONTENT_SKILL_CONFLICT: "该官方 Skill ID 已存在",
CONTENT_HINT_PACK_NOT_FOUND: "该语言的 Hint pack 尚未发布",
HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新",
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
@@ -228,6 +235,24 @@ export const adminApi = {
body: JSON.stringify(payload),
}),
hintFeedSettings: () =>
request<HintFeedSettings>("/content/hints/generation/settings"),
updateHintFeedSettings: (payload: UpdateHintFeedSettingsRequest) =>
request<HintFeedSettings>("/content/hints/generation/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
hintFeedStatus: () =>
request<HintFeedGenerationStatus>("/content/hints/generation/status"),
regenerateHintFeed: () =>
request<HintFeedGenerationResponse>("/content/hints/generation/regenerate", {
method: "POST",
signal: AbortSignal.timeout(130_000),
}),
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
const params =
typeof value === "string"
+41 -1
View File
@@ -16,7 +16,9 @@ export type AdminAuditAction =
| "CONTENT_SKILL_UPDATED"
| "CONTENT_SKILL_ENABLED"
| "CONTENT_SKILL_DISABLED"
| "CONTENT_HINT_PACK_PUBLISHED";
| "CONTENT_HINT_PACK_PUBLISHED"
| "CONTENT_HINT_FEED_SETTINGS_UPDATED"
| "CONTENT_HINT_FEED_GENERATED";
export interface SkillLocalization {
name: string;
@@ -82,6 +84,44 @@ export interface UpdateHintPackRequest {
cards: AIHintCard[];
}
export interface HintFeedSettings {
enabled: boolean;
topHubApiKeyConfigured: boolean;
generationIntervalHours: number;
holidayCountriesZh: string;
holidayCountriesEn: string;
weatherCitiesZh: string;
weatherCitiesEn: string;
googleTrendsGeos: string;
}
export type UpdateHintFeedSettingsRequest = Omit<
HintFeedSettings,
"enabled" | "topHubApiKeyConfigured"
>;
export interface HintFeedGenerationStatus {
enabled: boolean;
outcome: "IDLE" | "RUNNING" | "SUCCEEDED" | "FAILED";
intervalHours: number;
lastStartedAt?: string;
lastCompletedAt?: string;
lastErrorCode?: string;
nextScheduledAt?: string;
topHubApiKeyConfigured: boolean;
zhVersion?: number;
zhCardCount?: number;
enVersion?: number;
enCardCount?: number;
}
export interface HintFeedGenerationResponse {
generationId: string;
generatedAt: string;
zh: { version: number; cardCount: number };
en: { version: number; cardCount: number };
}
export interface CursorPageQuery {
cursor?: string;
limit?: number;
@@ -29,6 +29,7 @@ import {
Textarea,
} from "../../components/primitives";
import { useAuth } from "../auth/auth-context";
import { HintAutoSection } from "./hint-auto-section";
type HintLocale = "zh" | "en";
@@ -128,7 +129,7 @@ export function ContentPage() {
<PageHeader
eyebrow="Official Content"
title="内容管理"
description="维护客户端官方 Skill 目录与 zh/en AI Hint 发布包。每次保存都会立即发布并写入审计。"
description="维护客户端官方 Skill、AI Hint 自动生成与 zh/en 手动发布包。所有变更都会写入审计。"
actions={
canEdit ? (
<Button onClick={() => setEditingSkill("new")}>
@@ -212,6 +213,8 @@ export function ContentPage() {
)}
</section>
<HintAutoSection canEdit={canEdit} />
<section aria-labelledby="hint-heading" className="space-y-4">
<div>
<h2 id="hint-heading" className="text-xl font-bold">
@@ -0,0 +1,316 @@
import { Play, RefreshCw, Save, Settings2 } from "lucide-react";
import {
useCallback,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
HintFeedGenerationStatus,
HintFeedSettings,
UpdateHintFeedSettingsRequest,
} from "../../api/types";
import {
Badge,
Button,
Card,
Input,
LoadingState,
Textarea,
} from "../../components/primitives";
interface HintAutoSectionProps {
canEdit: boolean;
}
export function HintAutoSection({ canEdit }: HintAutoSectionProps) {
const [settings, setSettings] = useState<HintFeedSettings>();
const [status, setStatus] = useState<HintFeedGenerationStatus>();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [nextSettings, nextStatus] = await Promise.all([
adminApi.hintFeedSettings(),
adminApi.hintFeedStatus(),
]);
setSettings(nextSettings);
setStatus(nextStatus);
} catch (error) {
toast.error(message(error, "Hint 自动生成状态加载失败"));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!settings) return;
setSaving(true);
try {
const payload: UpdateHintFeedSettingsRequest = {
generationIntervalHours: settings.generationIntervalHours,
holidayCountriesZh: settings.holidayCountriesZh,
holidayCountriesEn: settings.holidayCountriesEn,
weatherCitiesZh: settings.weatherCitiesZh,
weatherCitiesEn: settings.weatherCitiesEn,
googleTrendsGeos: settings.googleTrendsGeos,
};
setSettings(await adminApi.updateHintFeedSettings(payload));
setStatus(await adminApi.hintFeedStatus());
toast.success("Hint 自动生成配置已保存");
} catch (error) {
toast.error(message(error, "Hint 自动生成配置保存失败"));
} finally {
setSaving(false);
}
}
async function regenerate() {
if (
!window.confirm(
"将立即抓取外部数据,并原子覆盖 zh/en 提示包。旧版本会保留到新一代全部生成成功。是否继续?",
)
) {
return;
}
setGenerating(true);
try {
const result = await adminApi.regenerateHintFeed();
toast.success(
`生成完成:zh ${result.zh.cardCount} 条,en ${result.en.cardCount}`,
);
await load();
} catch (error) {
toast.error(message(error, "Hint 提示包生成失败,旧版本仍保持可用"));
} finally {
setGenerating(false);
}
}
if (loading || !settings || !status) {
return <LoadingState label="加载 Hint 自动生成配置" />;
}
return (
<section aria-labelledby="hint-auto-heading" className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id="hint-auto-heading" className="text-xl font-bold">
Hint
</h2>
<p className="mt-1 text-sm text-muted">
TopHubGoogle TrendsGoogle News
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => void load()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
{canEdit ? (
<Button loading={generating} onClick={() => void regenerate()}>
<Play className="size-4" aria-hidden />
</Button>
) : null}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatusCard
label="调度"
value={settings.enabled ? `${status.intervalHours} 小时` : "未启用"}
tone={settings.enabled ? "success" : "neutral"}
/>
<StatusCard
label="上次结果"
value={outcomeLabel(status.outcome)}
detail={formatInstant(status.lastCompletedAt ?? status.lastStartedAt)}
tone={outcomeTone(status.outcome)}
/>
<StatusCard
label="中文包"
value={status.zhVersion ? `v${status.zhVersion}` : "未发布"}
detail={
status.zhCardCount === undefined ? undefined : `${status.zhCardCount}`
}
tone={status.zhVersion ? "success" : "warning"}
/>
<StatusCard
label="英文包"
value={status.enVersion ? `v${status.enVersion}` : "未发布"}
detail={
status.enCardCount === undefined ? undefined : `${status.enCardCount}`
}
tone={status.enVersion ? "success" : "warning"}
/>
</div>
<Card className="p-5 sm:p-6">
<form className="space-y-5" onSubmit={(event) => void save(event)}>
<div className="flex items-center gap-2">
<Settings2 className="size-4 text-primary" aria-hidden />
<h3 className="font-semibold"></h3>
<Badge className="ml-auto" tone={settings.topHubApiKeyConfigured ? "success" : "neutral"}>
TopHub Key {settings.topHubApiKeyConfigured ? "已配置" : "未配置"}
</Badge>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Field label="生成间隔(小时)">
<Input
type="number"
min={1}
max={168}
value={settings.generationIntervalHours}
readOnly={!canEdit}
onChange={(event) =>
setSettings({
...settings,
generationIntervalHours: Number(event.target.value),
})
}
/>
</Field>
<Field label="中文节日国家">
<Input
value={settings.holidayCountriesZh}
readOnly={!canEdit}
onChange={(event) =>
setSettings({ ...settings, holidayCountriesZh: event.target.value })
}
/>
</Field>
<Field label="英文节日国家">
<Input
value={settings.holidayCountriesEn}
readOnly={!canEdit}
onChange={(event) =>
setSettings({ ...settings, holidayCountriesEn: event.target.value })
}
/>
</Field>
</div>
<Field label="Google Trends 地区(逗号分隔)">
<Input
value={settings.googleTrendsGeos}
readOnly={!canEdit}
onChange={(event) =>
setSettings({ ...settings, googleTrendsGeos: event.target.value })
}
/>
</Field>
<div className="grid gap-4 lg:grid-cols-2">
<Field label="中文天气城市(城市:纬度,经度;…)">
<Textarea
className="min-h-24 font-mono text-xs"
value={settings.weatherCitiesZh}
readOnly={!canEdit}
onChange={(event) =>
setSettings({ ...settings, weatherCitiesZh: event.target.value })
}
/>
</Field>
<Field label="英文天气城市(城市:纬度,经度;…)">
<Textarea
className="min-h-24 font-mono text-xs"
value={settings.weatherCitiesEn}
readOnly={!canEdit}
onChange={(event) =>
setSettings({ ...settings, weatherCitiesEn: event.target.value })
}
/>
</Field>
</div>
<p className="text-xs text-muted">
TopHub API Key
JSON
</p>
{canEdit ? (
<Button type="submit" loading={saving}>
<Save className="size-4" aria-hidden />
</Button>
) : null}
</form>
</Card>
</section>
);
}
function StatusCard({
label,
value,
detail,
tone,
}: {
label: string;
value: string;
detail?: string;
tone: "success" | "warning" | "danger" | "neutral" | "info" | "violet";
}) {
return (
<Card className="p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</p>
<div className="mt-2 flex items-center gap-2">
<span className="font-semibold">{value}</span>
<Badge tone={tone}>{detail ?? value}</Badge>
</div>
</Card>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-sm font-semibold">
<span className="mb-2 block">{label}</span>
{children}
</label>
);
}
function outcomeLabel(outcome: HintFeedGenerationStatus["outcome"]): string {
return {
IDLE: "尚未运行",
RUNNING: "生成中",
SUCCEEDED: "成功",
FAILED: "失败",
}[outcome];
}
function outcomeTone(
outcome: HintFeedGenerationStatus["outcome"],
): "success" | "warning" | "danger" | "neutral" {
return {
IDLE: "neutral",
RUNNING: "warning",
SUCCEEDED: "success",
FAILED: "danger",
}[outcome] as "success" | "warning" | "danger" | "neutral";
}
function formatInstant(value?: string): string | undefined {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function message(error: unknown, fallback: string): string {
return error instanceof ApiError ? error.message : fallback;
}
+40
View File
@@ -27,6 +27,8 @@ describe("内容管理", () => {
const editor = await screen.findByLabelText("zh JSON");
expect(editor).toHaveProperty("readOnly", true);
expect(screen.queryByRole("button", { name: "保存并立即发布" })).toBeNull();
expect(screen.queryByRole("button", { name: "立即生成" })).toBeNull();
expect(screen.queryByRole("button", { name: "保存生成设置" })).toBeNull();
});
it("SUPER_ADMIN 可启停官方 Skill", async () => {
@@ -70,6 +72,24 @@ describe("内容管理", () => {
expect(input.getAttribute("maxlength")).toBe("6000");
});
});
it("SUPER_ADMIN 可手动触发双语 Hint 原子生成", async () => {
mockSession("SUPER_ADMIN");
mockContent();
vi.spyOn(window, "confirm").mockReturnValue(true);
const regenerate = vi.spyOn(adminApi, "regenerateHintFeed").mockResolvedValue({
generationId: "00000000-0000-0000-0000-000000000001",
generatedAt: "2026-08-21T06:00:00Z",
zh: { version: 3, cardCount: 20 },
en: { version: 3, cardCount: 25 },
});
window.location.hash = "#/content";
render(<App />);
await userEvent.click(await screen.findByRole("button", { name: "立即生成" }));
await waitFor(() => expect(regenerate).toHaveBeenCalledOnce());
});
});
function mockSession(role: AdminRole) {
@@ -89,6 +109,26 @@ function mockContent() {
version: 2,
cards: [],
});
vi.spyOn(adminApi, "hintFeedSettings").mockResolvedValue({
enabled: true,
topHubApiKeyConfigured: false,
generationIntervalHours: 12,
holidayCountriesZh: "CN",
holidayCountriesEn: "US,GB",
weatherCitiesZh: "北京:39.90,116.40",
weatherCitiesEn: "London:51.51,-0.13",
googleTrendsGeos: "US,GB",
});
vi.spyOn(adminApi, "hintFeedStatus").mockResolvedValue({
enabled: true,
outcome: "SUCCEEDED",
intervalHours: 12,
topHubApiKeyConfigured: false,
zhVersion: 2,
zhCardCount: 20,
enVersion: 2,
enCardCount: 25,
});
}
function catalog(): OfficialSkillCatalog {