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:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
从 TopHub、Google Trends、Google 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user