mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-02 15:01:14 +08:00
feat(prompt-sources): add custom script fetching functionality for prompt sources
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user