mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-28 19:14:30 +08:00
226 lines
9.0 KiB
TypeScript
226 lines
9.0 KiB
TypeScript
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<string, Promise<PromptSourceRefreshResult>>();
|
|
|
|
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<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 };
|
|
}
|
|
}
|
|
|
|
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<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(): Promise<Prompt[]> {
|
|
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<Prompt[]> {
|
|
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<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;
|
|
}
|
|
|
|
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) => {
|
|
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<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[] }) {
|
|
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);
|
|
}
|