mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(prompts): unify sources and add personal library
This commit is contained in:
+140
-46
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user