import localforage from "localforage"; import { runPromptSource, type RawPrompt } from "./prompt-source-runtime"; import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; import type { PromptSource } from "./prompt-source-presets"; export type Prompt = RawPrompt & { sourceId: string; category: string; githubUrl: string; }; export const ALL_PROMPTS_OPTION = "全部"; export type PromptListResponse = { items: Prompt[]; tags: string[]; categories: string[]; 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" }); const loadingSources = new Map>(); function enabledSources() { return usePromptSourceStore.getState().sources.filter((source) => source.enabled); } function cacheKey(sourceId: string) { return `prompt-source:${sourceId}`; } function sourceSignature(source: PromptSource) { 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; return `${value.length}:${hash}`; } function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] { 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 readSourceCache(sourceId: string) { return promptCacheStore.getItem(cacheKey(sourceId)); } async function refreshSourceRecord(source: PromptSource): Promise { 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 }; } } 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; } async function getSourcePrompts(source: PromptSource): Promise { 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(): Promise { const settled = await Promise.all( enabledSources().map(async (source) => { try { return await getSourcePrompts(source); } catch { return []; } }), ); return 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(); 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); return { items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize), tags: collectTags(withoutTagFilter), categories, total: filtered.length, }; } export async function fetchSourcePrompts(sourceId: string): Promise { const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId); if (!source) throw new Error("提示词来源不存在"); return getSourcePrompts(source); } export async function refreshSource(sourceId: string): Promise { 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; } export async function refreshAllSources(): Promise { const results = await Promise.all(enabledSources().map(getOrStartRefresh)); return summarizeRefresh(results); } export async function refreshDueSources(maxAgeMs: number): Promise { const sources = await Promise.all( enabledSources().map(async (source) => { 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; }), ); const results = await Promise.all(sources.filter((source): source is PromptSource => Boolean(source)).map(getOrStartRefresh)); return summarizeRefresh(results); } export async function fetchPromptSourceStatuses(): Promise> { 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[] }) { return items.filter((item) => { 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.description, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword); }); } function collectTags(items: Prompt[]) { return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean))); } function isActiveOption(value: string) { return value && value !== ALL_PROMPTS_OPTION && value !== "all"; } export function formatPromptDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(date); }