feat(prompts): unify sources and add personal library

This commit is contained in:
yu
2026-07-20 23:16:46 +08:00
parent c81bb8b651
commit 49851f2b5f
19 changed files with 736 additions and 539 deletions
+140 -46
View File
@@ -2,14 +2,17 @@ import localforage from "localforage";
import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { usePromptStore, type PersonalPrompt } from "@/stores/use-prompt-store";
import type { PromptSource } from "./prompt-source-presets";
export type Prompt = RawPrompt & {
sourceId: string;
category: string;
githubUrl: string;
};
export const ALL_PROMPTS_OPTION = "全部";
export const PERSONAL_PROMPTS_CATEGORY = "我的提示词";
export type PromptListResponse = {
items: Prompt[];
@@ -18,12 +21,34 @@ export type PromptListResponse = {
total: number;
};
export type PromptSourceStatus = {
sourceId: string;
count: number;
lastSuccessAt: string;
lastError: string;
};
export type PromptSourceRefreshResult = PromptSourceStatus & {
sourceName: string;
success: boolean;
};
export type PromptSourceRefreshSummary = {
results: PromptSourceRefreshResult[];
total: number;
successCount: number;
failureCount: number;
};
type SourceCache = PromptSourceStatus & {
items: Prompt[];
fetchedAt: number;
signature: string;
};
const cacheTtlMs = 1000 * 60 * 60;
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
type SourceCache = { items: Prompt[]; fetchedAt: number; signature: string };
const loadingSources = new Map<string, Promise<Prompt[]>>();
const loadingSources = new Map<string, Promise<PromptSourceRefreshResult>>();
function enabledSources() {
return usePromptSourceStore.getState().sources.filter((source) => source.enabled);
@@ -33,41 +58,84 @@ function cacheKey(sourceId: string) {
return `prompt-source:${sourceId}`;
}
/** Cheap stable signature of a source so cached prompts invalidate when the script or name changes. */
function sourceSignature(source: PromptSource) {
const value = `${source.name}\n${source.githubUrl}\n${source.script}`;
const value = `${source.name}\n${source.url}\n${source.homepage}`;
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) | 0;
return `${value.length}:${hash}`;
}
function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
return items.map((item) => ({ ...item, category: source.name, githubUrl: source.githubUrl }));
return items.map((item) => ({
...item,
description: item.description || "",
referenceImageUrls: Array.isArray(item.referenceImageUrls) ? item.referenceImageUrls : [],
sourceId: source.id,
category: source.name,
githubUrl: item.sourceUrl || source.homepage,
}));
}
async function runSource(source: PromptSource): Promise<Prompt[]> {
const items = await runPromptSource(source.script);
const prompts = withSourceMeta(source, items);
await promptCacheStore.setItem<SourceCache>(cacheKey(source.id), { items: prompts, fetchedAt: Date.now(), signature: sourceSignature(source) });
return prompts;
export function personalPromptToPrompt(item: PersonalPrompt): Prompt {
return {
...item,
coverUrl: item.coverUrl || item.referenceImageUrls[0] || "",
sourceId: "personal",
category: PERSONAL_PROMPTS_CATEGORY,
githubUrl: "",
preview: "",
};
}
async function getSourcePrompts(source: PromptSource, force = false): Promise<Prompt[]> {
const signature = sourceSignature(source);
if (!force) {
const cached = await promptCacheStore.getItem<SourceCache>(cacheKey(source.id));
if (cached?.items?.length && cached.signature === signature && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.items;
async function readSourceCache(sourceId: string) {
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
}
async function refreshSourceRecord(source: PromptSource): Promise<PromptSourceRefreshResult> {
const previous = await readSourceCache(source.id);
try {
const items = withSourceMeta(source, await runPromptSource(source));
const lastSuccessAt = new Date().toISOString();
const cache: SourceCache = { sourceId: source.id, items, count: items.length, fetchedAt: Date.now(), lastSuccessAt, lastError: "", signature: sourceSignature(source) };
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: items.length, lastSuccessAt, lastError: "", success: true };
} catch (error) {
const lastError = error instanceof Error ? error.message : String(error);
const cache: SourceCache = {
sourceId: source.id,
items: previous?.items || [],
count: previous?.items?.length || 0,
fetchedAt: previous?.fetchedAt || 0,
lastSuccessAt: previous?.lastSuccessAt || "",
lastError,
signature: previous?.signature || sourceSignature(source),
};
await promptCacheStore.setItem(cacheKey(source.id), cache);
return { sourceId: source.id, sourceName: source.name, count: cache.count, lastSuccessAt: cache.lastSuccessAt, lastError, success: false };
}
if (!force && loadingSources.has(source.id)) return loadingSources.get(source.id)!;
const loading = runSource(source).finally(() => loadingSources.delete(source.id));
}
function getOrStartRefresh(source: PromptSource) {
const current = loadingSources.get(source.id);
if (current) return current;
const loading = refreshSourceRecord(source).finally(() => loadingSources.delete(source.id));
loadingSources.set(source.id, loading);
return loading;
}
/** Aggregate prompts across all enabled sources; a failing source is skipped so others still load. */
async function getAllPrompts(): Promise<Prompt[]> {
async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
const cached = await readSourceCache(source.id);
if (cached) {
const stale = cached.signature !== sourceSignature(source) || Date.now() - cached.fetchedAt >= cacheTtlMs;
if (stale) void getOrStartRefresh(source).catch(() => undefined);
return withSourceMeta(source, cached.items);
}
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return (await readSourceCache(source.id))?.items || [];
}
async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
const settled = await Promise.all(
enabledSources().map(async (source) => {
try {
@@ -77,50 +145,76 @@ async function getAllPrompts(): Promise<Prompt[]> {
}
}),
);
return settled.flat();
const personal = includePersonal ? usePromptStore.getState().prompts.map(personalPromptToPrompt) : [];
return [...personal, ...settled.flat()];
}
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getAllPrompts();
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20, includePersonal = true }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number; includePersonal?: boolean } = {}) {
const items = await getAllPrompts(includePersonal);
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
const categories = enabledSources().map((source) => source.name);
if (includePersonal && usePromptStore.getState().prompts.length) categories.unshift(PERSONAL_PROMPTS_CATEGORY);
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories: enabledSources().map((source) => source.name),
categories,
total: filtered.length,
};
}
/** Load a single source's prompts (used by the source content table). Throws so the caller can show the error. */
export async function fetchSourcePrompts(sourceId: string, force = false): Promise<Prompt[]> {
export async function fetchSourcePrompts(sourceId: string): Promise<Prompt[]> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
return getSourcePrompts(source, force);
return getSourcePrompts(source);
}
/** Force refetch one source and refresh its cache; returns the fetched count. */
export async function refreshSource(sourceId: string): Promise<number> {
const items = await fetchSourcePrompts(sourceId, true);
return items.length;
export async function refreshSource(sourceId: string): Promise<PromptSourceRefreshResult> {
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
if (!source) throw new Error("提示词来源不存在");
const result = await getOrStartRefresh(source);
if (!result.success) throw new Error(result.lastError);
return result;
}
/** Force refetch every enabled source; returns the total prompt count. */
export async function refreshAllSources(): Promise<number> {
const settled = await Promise.all(
export async function refreshAllSources(): Promise<PromptSourceRefreshSummary> {
const results = await Promise.all(enabledSources().map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function refreshDueSources(maxAgeMs: number): Promise<PromptSourceRefreshSummary> {
const sources = await Promise.all(
enabledSources().map(async (source) => {
try {
return await getSourcePrompts(source, true);
} catch {
return [];
}
const cached = await readSourceCache(source.id);
const lastSuccess = cached?.lastSuccessAt ? new Date(cached.lastSuccessAt).getTime() : 0;
return !lastSuccess || Boolean(cached?.lastError) || Date.now() - lastSuccess >= maxAgeMs || cached?.signature !== sourceSignature(source) ? source : null;
}),
);
return settled.reduce((total, items) => total + items.length, 0);
const results = await Promise.all(sources.filter((source): source is PromptSource => Boolean(source)).map(getOrStartRefresh));
return summarizeRefresh(results);
}
export async function fetchPromptSourceStatuses(): Promise<Record<string, PromptSourceStatus>> {
const entries = await Promise.all(
usePromptSourceStore.getState().sources.map(async (source) => {
const cache = await readSourceCache(source.id);
return [source.id, { sourceId: source.id, count: cache?.items?.length || 0, lastSuccessAt: cache?.lastSuccessAt || "", lastError: cache?.lastError || "" }] as const;
}),
);
return Object.fromEntries(entries);
}
function summarizeRefresh(results: PromptSourceRefreshResult[]): PromptSourceRefreshSummary {
return {
results,
total: results.reduce((total, item) => total + item.count, 0),
successCount: results.filter((item) => item.success).length,
failureCount: results.filter((item) => !item.success).length,
};
}
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
@@ -128,7 +222,7 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st
if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
return [item.title, item.prompt, item.description, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
});
}
@@ -137,7 +231,7 @@ function collectTags(items: Prompt[]) {
}
function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all";
return value && value !== ALL_PROMPTS_OPTION && value !== "all";
}
export function formatPromptDate(value: string) {