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:
@@ -3,116 +3,35 @@ import { nanoid } from "nanoid";
|
||||
export type PromptSource = {
|
||||
id: string;
|
||||
name: string;
|
||||
githubUrl: string;
|
||||
url: string;
|
||||
homepage: string;
|
||||
enabled: boolean;
|
||||
script: string;
|
||||
builtIn: boolean;
|
||||
};
|
||||
|
||||
export const PROMPT_REGISTRY_HOMEPAGE = "https://github.com/yukkcat/image-prompts";
|
||||
const PROMPT_REGISTRY_SOURCE_BASE = "https://raw.githubusercontent.com/yukkcat/image-prompts/main/dist/sources";
|
||||
|
||||
export function createPromptSource(source?: Partial<PromptSource>): PromptSource {
|
||||
return {
|
||||
id: source?.id?.trim() || nanoid(),
|
||||
name: source?.name?.trim() || "新来源",
|
||||
githubUrl: source?.githubUrl?.trim() || "",
|
||||
url: source?.url?.trim() || "",
|
||||
homepage: source?.homepage?.trim() || "",
|
||||
enabled: source?.enabled ?? true,
|
||||
script: source?.script ?? "",
|
||||
builtIn: source?.builtIn ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const awesomeGptImageScript = `// awesome-gpt-image:从 README.zh-CN.md 解析,## 为标签分组,### 为单条提示词。
|
||||
const base = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main";
|
||||
const markdown = await fetchText(\`\${base}/README.zh-CN.md\`);
|
||||
const items = [];
|
||||
for (const section of splitSections(markdown, "## ")) {
|
||||
const tags = tagsFromHeading(firstMatch(section, /^##\\s+(.+)$/m));
|
||||
for (const block of splitSections(section, "### ")) {
|
||||
const title = firstMatch(block, /^###\\s+(.+)$/m).replace(/\\[([^\\]]+)]\\([^)]+\\)/g, "$1").trim();
|
||||
const prompt = firstMatch(block, /\\*\\*提示词:\\*\\*\\s*\\r?\\n\\s*\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim();
|
||||
if (!title || !prompt) continue;
|
||||
const images = extractImages(base, block);
|
||||
items.push(makePrompt({ id: \`awesome-gpt-image-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) }));
|
||||
}
|
||||
}
|
||||
return items;`;
|
||||
|
||||
const awesomeGpt4oImageScript = `// Awesome-GPT4o-Image-Prompts:README.zh-CN.md 里每个 ### 段落一条提示词。
|
||||
const base = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
|
||||
const markdown = await fetchText(\`\${base}/README.zh-CN.md\`);
|
||||
const items = [];
|
||||
for (const block of splitSections(markdown, "### ")) {
|
||||
const title = firstMatch(block, /^###\\s+(.+)$/m).trim();
|
||||
const prompt = firstMatch(block, /- \\*\\*提示词文本:\\*\\*\\s*\\\`(.*?)\\\`/s).trim();
|
||||
if (!title || !prompt) continue;
|
||||
const images = extractImages(base, block);
|
||||
items.push(makePrompt({ id: \`awesome-gpt4o-image-prompts-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: ["gpt4o"], preview: markdownPreview(images) }));
|
||||
}
|
||||
return items;`;
|
||||
|
||||
function youMindScript(base: string, idPrefix: string, modelTag: string) {
|
||||
return `// YouMind 系列:README_zh.md 里 "### No.N: 标题" + "#### ...提示词" 代码块。
|
||||
const base = "${base}";
|
||||
const idPrefix = "${idPrefix}";
|
||||
const modelTag = "${modelTag}";
|
||||
const markdown = await fetchText(\`\${base}/README_zh.md\`);
|
||||
const items = [];
|
||||
for (const block of splitSections(markdown, "### ")) {
|
||||
const title = firstMatch(block, /^###\\s+No\\.\\s*\\d+:\\s*(.+)$/m).trim();
|
||||
const prompt = firstMatch(block, /#### .*?提示词\\s*\\r?\\n\\s*\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim();
|
||||
if (!title || !prompt) continue;
|
||||
const images = extractImages(base, block);
|
||||
const [, prefix] = title.match(/^(.+?) - /) || [];
|
||||
const tags = [modelTag, ...tagsFromHeading(prefix || "")];
|
||||
items.push(makePrompt({ id: \`\${idPrefix}-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) }));
|
||||
}
|
||||
return items;`;
|
||||
}
|
||||
|
||||
const davidWuGptImage2Script = `// davidwu:prompts.json 结构化数据,逐条转换。
|
||||
const base = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
|
||||
const data = await fetchJson(\`\${base}/prompts.json\`);
|
||||
const items = [];
|
||||
data.forEach((item, index) => {
|
||||
const title = (item.title_cn || item.title_en || "").trim();
|
||||
const prompt = (item.prompt || "").trim();
|
||||
if (!title || !prompt) return;
|
||||
const image = absoluteUrl(base, item.image || "");
|
||||
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\\//);
|
||||
if (item.needs_ref) tags.push("需要参考图");
|
||||
const preview = [item.title_en, item.note, image ? \`\` : ""].filter(Boolean).join("\\n\\n");
|
||||
items.push(makePrompt({ id: \`davidwu-gpt-image2-prompts-\${leftPad(item.id || index + 1)}\`, title, prompt, coverUrl: image, tags, preview }));
|
||||
});
|
||||
return items;`;
|
||||
|
||||
export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [
|
||||
{ id: "davidwu-gpt-image2-prompts", name: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", enabled: true, script: davidWuGptImage2Script },
|
||||
{ id: "awesome-gpt-image", name: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", enabled: true, script: awesomeGptImageScript },
|
||||
{ id: "awesome-gpt4o-image-prompts", name: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", enabled: true, script: awesomeGpt4oImageScript },
|
||||
{
|
||||
id: "youmind-gpt-image-2",
|
||||
name: "youmind-gpt-image-2",
|
||||
githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2",
|
||||
enabled: true,
|
||||
script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main", "youmind-gpt-image-2", "gpt-image-2"),
|
||||
},
|
||||
{
|
||||
id: "youmind-nano-banana-pro",
|
||||
name: "youmind-nano-banana-pro",
|
||||
githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts",
|
||||
enabled: true,
|
||||
script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main", "youmind-nano-banana-pro", "nano-banana-pro"),
|
||||
},
|
||||
registrySource("banana-prompt-quicker", "Banana Prompt Quicker", "https://glidea.github.io/banana-prompt-quicker/"),
|
||||
registrySource("davidwu-gpt-image2-prompts", "DavidWu GPT Image 2", "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts"),
|
||||
registrySource("awesome-gpt-image", "Awesome GPT Image", "https://github.com/ZeroLu/awesome-gpt-image"),
|
||||
registrySource("awesome-gpt4o-image-prompts", "Awesome GPT-4o", "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts"),
|
||||
registrySource("youmind-gpt-image-2", "YouMind GPT Image 2", "https://github.com/YouMind-OpenLab/awesome-gpt-image-2"),
|
||||
registrySource("youmind-nano-banana-pro", "YouMind Nano Banana Pro", "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts"),
|
||||
];
|
||||
|
||||
/** Starter script inserted when a user creates a blank source. */
|
||||
export const PROMPT_SOURCE_TEMPLATE = `// 拉取远程列表并 return 一个提示词数组;每条至少含 title 和 prompt。
|
||||
// 可用辅助见右侧「可用变量」,例如 fetchText / splitSections / makePrompt。
|
||||
const base = "https://raw.githubusercontent.com/owner/repo/main";
|
||||
const markdown = await fetchText(\`\${base}/README.md\`);
|
||||
const items = [];
|
||||
for (const block of splitSections(markdown, "### ")) {
|
||||
const title = firstMatch(block, /^###\\s+(.+)$/m).trim();
|
||||
const prompt = firstMatch(block, /\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim();
|
||||
if (!title || !prompt) continue;
|
||||
const images = extractImages(base, block);
|
||||
items.push(makePrompt({ id: \`my-source-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: [], preview: markdownPreview(images) }));
|
||||
function registrySource(id: string, name: string, homepage: string): PromptSource {
|
||||
return { id, name, url: `${PROMPT_REGISTRY_SOURCE_BASE}/${id}.json`, homepage, enabled: true, builtIn: true };
|
||||
}
|
||||
return items;`;
|
||||
|
||||
@@ -1,165 +1,118 @@
|
||||
/**
|
||||
* Runtime for user-authored prompt-source scripts. A script is an async function body that fetches
|
||||
* a remote list (markdown / json) and `return`s an array of prompt items. It runs with a set of flat
|
||||
* helper locals (see PROMPT_SOURCE_VARIABLES) so scripts stay short and declarative.
|
||||
*/
|
||||
import type { PromptSource } from "./prompt-source-presets";
|
||||
|
||||
export type RawPrompt = {
|
||||
id: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
prompt: string;
|
||||
description: string;
|
||||
coverUrl: string;
|
||||
referenceImageUrls: string[];
|
||||
tags: string[];
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author?: string;
|
||||
sourceUrl?: string;
|
||||
imageMode?: string;
|
||||
imageModel?: string;
|
||||
imageSize?: string;
|
||||
imageCount?: number;
|
||||
};
|
||||
|
||||
type RunOptions = { signal?: AbortSignal };
|
||||
|
||||
async function fetchText(url: string) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${url} 拉取失败`);
|
||||
return response.text();
|
||||
async function fetchSource(source: PromptSource, options?: RunOptions) {
|
||||
const response = await fetch(source.url, { cache: "no-store", signal: options?.signal });
|
||||
if (!response.ok) throw new Error(`请求失败(${response.status})`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string) {
|
||||
return JSON.parse(await fetchText(url)) as T;
|
||||
}
|
||||
|
||||
/** Split markdown into blocks, each starting at a line that begins with `prefix` (e.g. "## " / "### "). */
|
||||
function splitSections(markdown: string, prefix: string) {
|
||||
const blocks: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of markdown.split("\n")) {
|
||||
if (line.startsWith(prefix) && current.length) {
|
||||
blocks.push(current.join("\n"));
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
export async function runPromptSource(source: PromptSource, options?: RunOptions): Promise<RawPrompt[]> {
|
||||
if (!source.url.trim()) throw new Error("JSON URL 不能为空");
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await fetchSource(source, options);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new Error(`「${source.name}」拉取失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
blocks.push(current.join("\n"));
|
||||
return blocks;
|
||||
|
||||
const items = parseJsonSource(data, source);
|
||||
if (source.builtIn && !items.length) throw new Error(`「${source.name}」未解析到有效提示词`);
|
||||
return items;
|
||||
}
|
||||
|
||||
function firstMatch(value: string, pattern: RegExp) {
|
||||
return pattern.exec(value)?.[1] || "";
|
||||
function parseJsonSource(data: unknown, source: PromptSource) {
|
||||
if (!Array.isArray(data)) throw new Error(`「${source.name}」格式错误:根节点必须是数组`);
|
||||
return normalizeItems(data, source);
|
||||
}
|
||||
|
||||
function normalizeItems(values: unknown[], source: PromptSource) {
|
||||
const seen = new Set<string>();
|
||||
const items: RawPrompt[] = [];
|
||||
values.forEach((value, index) => {
|
||||
const record = asRecord(value);
|
||||
const title = stringValue(record.title).trim();
|
||||
const prompt = stringValue(record.prompt).trim();
|
||||
if (!title || !prompt) return;
|
||||
const id = stringValue(record.id).trim() || `${source.id}-${leftPad(index + 1)}`;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
const referenceImageUrls = stringArray(record.referenceImageUrls).map((url) => absoluteUrl(source.url, url));
|
||||
const coverUrl = absoluteUrl(source.url, stringValue(record.coverUrl)) || referenceImageUrls[0] || "";
|
||||
items.push({
|
||||
id,
|
||||
title,
|
||||
prompt,
|
||||
description: stringValue(record.description),
|
||||
coverUrl,
|
||||
referenceImageUrls,
|
||||
tags: stringArray(record.tags),
|
||||
preview: stringValue(record.preview),
|
||||
createdAt: stringValue(record.createdAt),
|
||||
updatedAt: stringValue(record.updatedAt),
|
||||
author: stringValue(record.author),
|
||||
sourceUrl: absoluteUrl(source.url, stringValue(record.sourceUrl)),
|
||||
imageMode: optionalString(record.imageMode),
|
||||
imageModel: optionalString(record.imageModel),
|
||||
imageSize: optionalString(record.imageSize),
|
||||
imageCount: optionalNumber(record.imageCount),
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.map(stringValue).map((item) => item.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
const result = stringValue(value).trim();
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown) {
|
||||
const result = Number(value);
|
||||
return Number.isFinite(result) && result > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function absoluteUrl(baseUrl: string, path: string) {
|
||||
if (!path) return "";
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${baseUrl}/${path.replace(/^\.?\//, "")}`;
|
||||
}
|
||||
|
||||
function extractImages(baseUrl: string, markdown: string) {
|
||||
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteUrl(baseUrl, match[1])).filter(Boolean);
|
||||
}
|
||||
|
||||
function splitTags(value: string, pattern: RegExp) {
|
||||
return value
|
||||
.split(pattern)
|
||||
.map((tag) => tag.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function tagsFromHeading(heading: string) {
|
||||
return splitTags(heading.replace(/[^\p{L}\p{N}/&、与 ]/gu, ""), /\s*(?:\/|&|、|与)\s*/);
|
||||
}
|
||||
|
||||
function markdownPreview(images: string[]) {
|
||||
return images
|
||||
.filter(Boolean)
|
||||
.map((image) => ``)
|
||||
.join("\n\n");
|
||||
try {
|
||||
return new URL(path, baseUrl).toString();
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function leftPad(value: number) {
|
||||
return String(value).padStart(4, "0");
|
||||
}
|
||||
|
||||
function makePrompt(input: { id: string; title: string; prompt: string; coverUrl?: string; tags?: string[]; preview?: string; createdAt?: string; updatedAt?: string }): RawPrompt {
|
||||
return {
|
||||
id: input.id,
|
||||
title: input.title,
|
||||
prompt: input.prompt,
|
||||
coverUrl: input.coverUrl || "",
|
||||
tags: input.tags || [],
|
||||
preview: input.preview || "",
|
||||
createdAt: input.createdAt || "",
|
||||
updatedAt: input.updatedAt || "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a prompt-source script and normalize its result into a deduped RawPrompt[]. */
|
||||
export async function runPromptSource(script: string, options?: RunOptions): Promise<RawPrompt[]> {
|
||||
const body = script.trim();
|
||||
if (!body) throw new Error("提示词来源脚本为空");
|
||||
const runner = new Function(
|
||||
"fetchText",
|
||||
"fetchJson",
|
||||
"splitSections",
|
||||
"firstMatch",
|
||||
"extractImages",
|
||||
"absoluteUrl",
|
||||
"tagsFromHeading",
|
||||
"splitTags",
|
||||
"markdownPreview",
|
||||
"leftPad",
|
||||
"makePrompt",
|
||||
"signal",
|
||||
`"use strict"; return (async () => {\n${body}\n})();`,
|
||||
) as (...args: unknown[]) => Promise<unknown>;
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await runner(fetchText, fetchJson, splitSections, firstMatch, extractImages, absoluteUrl, tagsFromHeading, splitTags, markdownPreview, leftPad, makePrompt, options?.signal);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`提示词来源脚本执行失败:${message}`);
|
||||
}
|
||||
if (!Array.isArray(result)) throw new Error("提示词来源脚本需要 return 一个数组");
|
||||
const seen = new Set<string>();
|
||||
const items: RawPrompt[] = [];
|
||||
for (const raw of result) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const title = String(record.title || "").trim();
|
||||
const prompt = String(record.prompt || "").trim();
|
||||
if (!title || !prompt) continue;
|
||||
const id = String(record.id || "").trim() || `prompt-${leftPad(items.length + 1)}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
items.push(
|
||||
makePrompt({
|
||||
id,
|
||||
title,
|
||||
prompt,
|
||||
coverUrl: String(record.coverUrl || ""),
|
||||
tags: Array.isArray(record.tags) ? record.tags.map((tag) => String(tag)).filter(Boolean) : [],
|
||||
preview: String(record.preview || ""),
|
||||
createdAt: String(record.createdAt || ""),
|
||||
updatedAt: String(record.updatedAt || ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export type PromptSourceVariable = { name: string; type: string; desc: string };
|
||||
|
||||
/** Documentation surface shown in the prompt-source script editor. */
|
||||
export const PROMPT_SOURCE_VARIABLES: PromptSourceVariable[] = [
|
||||
{ name: "fetchText", type: "function", desc: "fetchText(url) 拉取纯文本(README 等),失败抛错" },
|
||||
{ name: "fetchJson", type: "function", desc: "fetchJson(url) 拉取并解析 JSON" },
|
||||
{ name: "splitSections", type: "function", desc: "splitSections(markdown, prefix) 按标题前缀(如 '### ')切分成段落数组" },
|
||||
{ name: "firstMatch", type: "function", desc: "firstMatch(text, /正则/) 返回第一个捕获组,未匹配返回空串" },
|
||||
{ name: "extractImages", type: "function", desc: "extractImages(baseUrl, markdown) 提取 markdown 图片并补全为绝对地址" },
|
||||
{ name: "absoluteUrl", type: "function", desc: "absoluteUrl(baseUrl, path) 把相对路径拼成绝对 URL" },
|
||||
{ name: "tagsFromHeading", type: "function", desc: "tagsFromHeading(heading) 从标题按 / & 、与 切出标签(小写去重前)" },
|
||||
{ name: "splitTags", type: "function", desc: "splitTags(value, /分隔符/) 切分标签并转小写去空" },
|
||||
{ name: "markdownPreview", type: "function", desc: "markdownPreview(images) 把图片数组拼成 markdown 预览文本" },
|
||||
{ name: "leftPad", type: "function", desc: "leftPad(n) 数字左补零到 4 位,用于生成有序 id" },
|
||||
{ name: "makePrompt", type: "function", desc: "makePrompt({id,title,prompt,coverUrl,tags,preview}) 构造一条提示词;title 和 prompt 必填" },
|
||||
{ name: "signal", type: "AbortSignal", desc: "取消信号,可透传给需要的请求" },
|
||||
];
|
||||
|
||||
+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