feat(prompts): unify sources and add personal library
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { App, Button, Select, Switch } from "antd";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Select, Switch, Tag } from "antd";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
|
||||
import { PromptSourceContentModal } from "./prompt-source-content-modal";
|
||||
import { refreshAllSources, refreshSource } from "@/services/api/prompts";
|
||||
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
|
||||
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
||||
import type { PromptSource } from "@/services/api/prompt-source-presets";
|
||||
|
||||
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
|
||||
|
||||
export function ConfigPromptSources() {
|
||||
const { message } = App.useApp();
|
||||
const { message, modal } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const sources = usePromptSourceStore((state) => state.sources);
|
||||
const schedule = usePromptSourceStore((state) => state.schedule);
|
||||
@@ -19,20 +21,20 @@ export function ConfigPromptSources() {
|
||||
const removeSource = usePromptSourceStore((state) => state.removeSource);
|
||||
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
|
||||
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
|
||||
const statusQuery = useQuery({ queryKey: STATUS_QUERY_KEY, queryFn: fetchPromptSourceStatuses });
|
||||
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editingSource, setEditingSource] = useState<PromptSource | null>(null);
|
||||
const [viewingId, setViewingId] = useState("");
|
||||
const [refreshingId, setRefreshingId] = useState("");
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
|
||||
const editingSource = sources.find((item) => item.id === editingId) || null;
|
||||
const viewingSource = sources.find((item) => item.id === viewingId) || null;
|
||||
|
||||
const invalidatePrompts = () => queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
||||
|
||||
const handleAdd = () => {
|
||||
const source = addSource();
|
||||
setEditingId(source.id);
|
||||
const invalidatePrompts = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
|
||||
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSave = (source: PromptSource) => {
|
||||
@@ -41,22 +43,28 @@ export function ConfigPromptSources() {
|
||||
};
|
||||
|
||||
const handleDelete = (source: PromptSource) => {
|
||||
if (sources.length <= 1) {
|
||||
message.warning("至少保留一个来源");
|
||||
return;
|
||||
}
|
||||
removeSource(source.id);
|
||||
void invalidatePrompts();
|
||||
modal.confirm({
|
||||
title: `删除「${source.name}」?`,
|
||||
content: "来源配置会被移除,已经保存到我的提示词的内容不受影响。",
|
||||
okText: "删除",
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
removeSource(source.id);
|
||||
await invalidatePrompts();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefreshOne = async (source: PromptSource) => {
|
||||
setRefreshingId(source.id);
|
||||
try {
|
||||
const count = await refreshSource(source.id);
|
||||
const result = await refreshSource(source.id);
|
||||
await invalidatePrompts();
|
||||
message.success(`「${source.name}」已拉取 ${count} 条`);
|
||||
message.success(`「${source.name}」已更新 ${result.count} 条`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取失败");
|
||||
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
|
||||
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存");
|
||||
} finally {
|
||||
setRefreshingId("");
|
||||
}
|
||||
@@ -65,12 +73,13 @@ export function ConfigPromptSources() {
|
||||
const handleRefreshAll = async () => {
|
||||
setRefreshingAll(true);
|
||||
try {
|
||||
const count = await refreshAllSources();
|
||||
const result = await refreshAllSources();
|
||||
updateSchedule("lastFetchedAt", new Date().toISOString());
|
||||
await invalidatePrompts();
|
||||
message.success(`全部来源已拉取,共 ${count} 条`);
|
||||
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
|
||||
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total} 条`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取失败");
|
||||
message.error(error instanceof Error ? error.message : "更新失败");
|
||||
} finally {
|
||||
setRefreshingAll(false);
|
||||
}
|
||||
@@ -78,40 +87,48 @@ export function ConfigPromptSources() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-stone-500">每个来源是一段拉取脚本,可自定义、可查看内容;系统内置几个默认来源,你也可以本地新增。</div>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={handleAdd}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
|
||||
新增来源
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sources.map((source) => (
|
||||
<div key={source.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Switch size="small" checked={source.enabled} onChange={(checked) => toggleSource(source.id, checked)} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{source.name || "未命名来源"}</div>
|
||||
<div className="mt-1 truncate text-xs text-stone-500">{source.githubUrl || "无 GitHub 地址"}</div>
|
||||
{sources.map((source) => {
|
||||
const status = statusQuery.data?.[source.id];
|
||||
return (
|
||||
<div key={source.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||
<Switch size="small" checked={source.enabled} onChange={(checked) => { toggleSource(source.id, checked); void invalidatePrompts(); }} />
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{source.name}</span>
|
||||
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]">内置</Tag> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
|
||||
{source.homepage || source.url}
|
||||
</a>
|
||||
<span className="tabular-nums">{status?.count ?? 0} 条</span>
|
||||
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}>失败</Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]">正常</Tag> : <Tag className="m-0 text-[10px]">未同步</Tag>}
|
||||
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap justify-end gap-2">
|
||||
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
|
||||
查看内容
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
|
||||
立即拉取
|
||||
</Button>
|
||||
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>编辑来源</Button> : null}
|
||||
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}>删除</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
|
||||
查看内容
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
|
||||
立即拉取
|
||||
</Button>
|
||||
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingId(source.id)}>
|
||||
编辑脚本
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<div className="mb-3 text-sm font-semibold">定时拉取</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -126,12 +143,13 @@ export function ConfigPromptSources() {
|
||||
<div className="mt-2 text-xs text-stone-400">开启周期后,页面打开期间会按周期自动拉取所有启用的来源。</div>
|
||||
</section>
|
||||
|
||||
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingId("")} />
|
||||
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
|
||||
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
||||
<div className="mt-0.5 text-xs font-normal text-stone-500">共 {items.length} 条</div>
|
||||
</div>
|
||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
|
||||
立即拉取
|
||||
立即更新
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { Button, Drawer, Input, Space } from "antd";
|
||||
import { App, Button, Drawer, Input, Space, Switch } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PROMPT_SOURCE_VARIABLES } from "@/services/api/prompt-source-runtime";
|
||||
import { PROMPT_SOURCE_TEMPLATE, type PromptSource } from "@/services/api/prompt-source-presets";
|
||||
|
||||
function isDarkMode() {
|
||||
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
||||
}
|
||||
import type { PromptSource } from "@/services/api/prompt-source-presets";
|
||||
|
||||
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [draft, setDraft] = useState<PromptSource | null>(source);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,15 +16,20 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
||||
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
||||
|
||||
const save = () => {
|
||||
onSave({ ...draft, name: draft.name.trim() || "未命名来源", githubUrl: draft.githubUrl.trim(), script: draft.script.trim() });
|
||||
const name = draft.name.trim();
|
||||
const url = draft.url.trim();
|
||||
if (!name) return message.warning("请输入来源名称");
|
||||
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
|
||||
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
|
||||
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
width={880}
|
||||
title="编辑提示词来源"
|
||||
width={560}
|
||||
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
|
||||
onClose={onClose}
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
extra={
|
||||
@@ -42,67 +41,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">来源名称</span>
|
||||
<span className="mb-1.5 block text-sm font-medium">来源名称</span>
|
||||
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">GitHub 地址(可选)</span>
|
||||
<Input value={draft.githubUrl} onChange={(event) => patch({ githubUrl: event.target.value })} placeholder="https://github.com/owner/repo" />
|
||||
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
|
||||
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">拉取脚本</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">脚本是一段异步函数体,直接使用下方变量,最后 return 一个提示词数组(每条至少含 title 和 prompt)。</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">来源主页(可选)</span>
|
||||
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
|
||||
</label>
|
||||
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
|
||||
<span className="text-sm font-medium">启用来源</span>
|
||||
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
|
||||
</div>
|
||||
<Button size="small" onClick={() => patch({ script: PROMPT_SOURCE_TEMPLATE })}>
|
||||
插入模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[56vh] min-h-[420px] overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
|
||||
<aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">可用变量</span>
|
||||
<span className="text-[10px] text-stone-400">点击插入</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{PROMPT_SOURCE_VARIABLES.map((variable) => (
|
||||
<button
|
||||
key={variable.name}
|
||||
type="button"
|
||||
onClick={() => patch({ script: draft.script ? `${draft.script}\n${variable.name}` : variable.name })}
|
||||
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
|
||||
{variable.name}
|
||||
</code>
|
||||
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
|
||||
<CodeMirror
|
||||
value={draft.script}
|
||||
onChange={(value) => patch({ script: value })}
|
||||
height="100%"
|
||||
theme={isDarkMode() ? "dark" : "light"}
|
||||
extensions={[javascript()]}
|
||||
placeholder={"// return 一个提示词数组;点击右上角「插入模板」查看示例。"}
|
||||
style={{ height: "100%", fontSize: 13 }}
|
||||
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 text-sm font-medium">JSON 格式</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
|
||||
{
|
||||
"id": "product-photo-1",
|
||||
"title": "白底商品图",
|
||||
"prompt": "生成专业白底商品摄影图",
|
||||
"description": "",
|
||||
"coverUrl": "",
|
||||
"referenceImageUrls": [],
|
||||
"tags": ["商品", "摄影"]
|
||||
}
|
||||
]`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
try {
|
||||
return ["http:", "https:"].includes(new URL(value).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user