feat(prompt-sources): add custom script fetching functionality for prompt sources

This commit is contained in:
HouYunFei
2026-07-17 17:36:42 +08:00
parent 0e1edd1484
commit d4130bbb79
14 changed files with 851 additions and 229 deletions
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
@@ -240,6 +241,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
</Form>
),
},
{
key: "prompt-sources",
label: "提示词来源",
children: <ConfigPromptSources />,
},
{
key: "webdav",
label: "WebDAV",
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import { App } from "antd";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
@@ -11,6 +12,8 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const config = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
usePromptSourceScheduler();
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
@@ -0,0 +1,137 @@
import { App, Button, Select, Switch } from "antd";
import { 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 { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function ConfigPromptSources() {
const { message } = App.useApp();
const queryClient = useQueryClient();
const sources = usePromptSourceStore((state) => state.sources);
const schedule = usePromptSourceStore((state) => state.schedule);
const addSource = usePromptSourceStore((state) => state.addSource);
const saveSource = usePromptSourceStore((state) => state.saveSource);
const removeSource = usePromptSourceStore((state) => state.removeSource);
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
const [editingId, setEditingId] = useState("");
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 handleSave = (source: PromptSource) => {
saveSource(source);
void invalidatePrompts();
};
const handleDelete = (source: PromptSource) => {
if (sources.length <= 1) {
message.warning("至少保留一个来源");
return;
}
removeSource(source.id);
void invalidatePrompts();
};
const handleRefreshOne = async (source: PromptSource) => {
setRefreshingId(source.id);
try {
const count = await refreshSource(source.id);
await invalidatePrompts();
message.success(`${source.name}」已拉取 ${count}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取失败");
} finally {
setRefreshingId("");
}
};
const handleRefreshAll = async () => {
setRefreshingAll(true);
try {
const count = await refreshAllSources();
updateSchedule("lastFetchedAt", new Date().toISOString());
await invalidatePrompts();
message.success(`全部来源已拉取,共 ${count}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取失败");
} finally {
setRefreshingAll(false);
}
};
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}>
</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>
</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">
<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">
<span className="text-xs text-stone-500"></span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
</Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span>
</div>
<div className="mt-2 text-xs text-stone-400"></div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingId("")} />
<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" });
}
@@ -0,0 +1,130 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchSourcePrompts, refreshSource, type Prompt } from "@/services/api/prompts";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp();
const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null);
const copyText = useCopyText();
const addAsset = useAssetStore((state) => state.addAsset);
const load = useCallback(
async (force: boolean) => {
if (!source) return;
setLoading(true);
try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败");
} finally {
setLoading(false);
}
},
[source, message],
);
useEffect(() => {
if (source) void load(false);
else setItems([]);
}, [source, load]);
const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产");
};
return (
<>
<Modal
open={Boolean(source)}
onCancel={onClose}
width={980}
footer={null}
title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div>
<div className="text-base font-semibold">{source?.name || ""} · </div>
<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>
}
>
<Table<Prompt>
rowKey="id"
size="small"
loading={loading}
dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }}
columns={[
{
title: "封面",
dataIndex: "coverUrl",
width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
},
{
title: "标题",
dataIndex: "title",
render: (title: string, item) => (
<div className="min-w-0">
<div className="truncate font-medium">{title}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-stone-500">{item.prompt}</div>
</div>
),
},
{
title: "标签",
dataIndex: "tags",
width: 200,
render: (tags: string[]) => (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
),
},
{
title: "操作",
width: 210,
render: (_, item) => (
<Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}>
</Button>
<Button size="small" type="text" onClick={() => setDetail(item)}>
</Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
</Button>
</Space>
),
},
]}
/>
</Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} />
</>
);
}
async function refreshSourceItems(sourceId: string) {
await refreshSource(sourceId);
return fetchSourcePrompts(sourceId);
}
@@ -0,0 +1,108 @@
import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror";
import { Button, Drawer, Input, Space } 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");
}
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => {
if (open && source) setDraft(source);
}, [open, source]);
if (!draft) return null;
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() });
onClose();
};
return (
<Drawer
open={open}
width={880}
title="编辑提示词来源"
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 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" />
</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>
</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>
</Drawer>
);
}