diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d11c1..4cea4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased ++ [新增] 管理后台提示词管理支持多选批量删除。 ++ [新增] 新增定义拉取GitHub提示词源功能。 + +## v0.0.6 - 2026-05-22 + + [新增] 管理后台支持配置模型渠道,前端当前无需鉴权即可直接使用后端渠道能力。 + [优化] 统一整理后端错误提示、AI 代理、图片节点生成与重试、参考图缺失处理等细节。 + [优化] 后端模型代理路径调整为 OpenAI 风格。 diff --git a/docs/backend-database.md b/docs/backend-database.md index 0bcc21f..d657e6a 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -129,6 +129,7 @@ | 字段 | 类型 | 说明 | |------------|----------|----------| | `channels` | object[] | 模型渠道配置列表 | +| `promptSync` | object | GitHub 远程提示词定时同步配置 | `channels` 每项字段: @@ -143,6 +144,13 @@ | `enabled` | bool | 是否启用 | | `remark` | string | 备注 | +`promptSync` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 是否开启定时同步 | +| `cron` | string | Cron 表达式,默认每天 03:00 | + 后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 ### dicts diff --git a/docs/pending-test.md b/docs/pending-test.md index 8e27a2d..68c5021 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,3 +1,5 @@ # 待测试 +- 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。 +- 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。 - 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。 diff --git a/docs/system-settings.md b/docs/system-settings.md index 4cf49a2..de22913 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -59,13 +59,18 @@ "enabled": true, "remark": "" } - ] + ], + "promptSync": { + "enabled": false, + "cron": "0 3 * * *" + } } ``` | 字段 | 类型 | 说明 | | --- | --- | --- | | `channels` | object[] | 模型渠道列表 | +| `promptSync` | object | GitHub 远程提示词定时同步配置 | `channels` 每项字段: @@ -81,3 +86,10 @@ | `remark` | string | 备注 | 后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。 + +`promptSync` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | boolean | 是否开启定时同步 | +| `cron` | string | Cron 表达式,默认每天 03:00 | diff --git a/go.mod b/go.mod index a365603..5891d74 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.48.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 diff --git a/go.sum b/go.sum index 452518f..d6a9516 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/handler/admin.go b/handler/admin.go index 950fb1d..1db1aaa 100644 --- a/handler/admin.go +++ b/handler/admin.go @@ -13,6 +13,10 @@ type adminSyncRequest struct { Category string `json:"category"` } +type adminBatchDeleteRequest struct { + IDs []string `json:"ids"` +} + func AdminPromptCategories(w http.ResponseWriter, r *http.Request) { OK(w, service.ListPromptCategories()) } @@ -45,6 +49,16 @@ func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) { OK(w, true) } +func AdminDeletePrompts(w http.ResponseWriter, r *http.Request) { + var request adminBatchDeleteRequest + _ = json.NewDecoder(r.Body).Decode(&request) + if err := service.DeletePrompts(request.IDs); err != nil { + FailError(w, err) + return + } + OK(w, true) +} + func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) { var request adminSyncRequest _ = json.NewDecoder(r.Body).Decode(&request) diff --git a/main.go b/main.go index 650105f..4128870 100644 --- a/main.go +++ b/main.go @@ -15,5 +15,6 @@ func main() { if err := service.EnsureDefaultAdmin(); err != nil { log.Fatal(err) } + service.StartPromptSyncScheduler() log.Fatal(router.New().Run(":" + config.Cfg.Port)) } diff --git a/model/setting.go b/model/setting.go index d0ef79b..f4786ab 100644 --- a/model/setting.go +++ b/model/setting.go @@ -38,7 +38,14 @@ type PublicSetting struct { // PrivateSetting 私有配置。 type PrivateSetting struct { - Channels []ModelChannel `json:"channels"` + Channels []ModelChannel `json:"channels"` + PromptSync PromptSyncSetting `json:"promptSync"` +} + +// PromptSyncSetting 提示词定时同步配置。 +type PromptSyncSetting struct { + Enabled bool `json:"enabled"` + Cron string `json:"cron"` } // Setting 系统配置。 diff --git a/repository/prompt.go b/repository/prompt.go index 0d1cfaa..40abcc4 100644 --- a/repository/prompt.go +++ b/repository/prompt.go @@ -99,6 +99,15 @@ func DeletePrompt(id string) error { return db.Delete(&model.Prompt{}, "id = ?", id).Error } +// DeletePrompts 批量删除提示词。 +func DeletePrompts(ids []string) error { + db, err := DB() + if err != nil { + return err + } + return db.Delete(&model.Prompt{}, "id IN ?", ids).Error +} + // ReplacePromptCategory 用远程同步结果替换整个提示词分类。 func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error { db, err := DB() diff --git a/router/router.go b/router/router.go index f00f714..59e5a75 100644 --- a/router/router.go +++ b/router/router.go @@ -41,6 +41,7 @@ func New() *gin.Engine { admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories)) admin.GET("/prompts", gin.WrapF(handler.AdminPrompts)) admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt)) + admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts)) admin.DELETE("/prompts/:id", func(c *gin.Context) { handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id")) }) diff --git a/service/prompt_sync_scheduler.go b/service/prompt_sync_scheduler.go new file mode 100644 index 0000000..4951ba9 --- /dev/null +++ b/service/prompt_sync_scheduler.go @@ -0,0 +1,70 @@ +package service + +import ( + "log" + "sync" + + "github.com/basketikun/infinite-canvas/model" + "github.com/basketikun/infinite-canvas/repository" + "github.com/robfig/cron/v3" +) + +const defaultPromptSyncCron = "0 3 * * *" + +var ( + promptSyncCron *cron.Cron + promptSyncOnce sync.Once + promptSyncMu sync.Mutex +) + +func StartPromptSyncScheduler() { + promptSyncOnce.Do(func() { + promptSyncCron = cron.New() + promptSyncCron.Start() + }) + RefreshPromptSyncScheduler() +} + +func RefreshPromptSyncScheduler() { + promptSyncMu.Lock() + defer promptSyncMu.Unlock() + if promptSyncCron == nil { + return + } + for _, entry := range promptSyncCron.Entries() { + promptSyncCron.Remove(entry.ID) + } + settings, err := repository.GetSettings() + if err != nil { + log.Printf("load prompt sync setting failed err=%v", err) + return + } + setting := normalizePromptSyncSetting(settings.Private.PromptSync) + if !setting.Enabled { + return + } + if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil { + log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err) + } +} + +func SyncRemotePromptCategories() { + for _, category := range repository.PromptCategories() { + if !category.Remote { + continue + } + log.Printf("scheduled prompt sync start category=%s", category.Category) + if _, err := SyncPromptCategory(category.Category); err != nil { + log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err) + continue + } + log.Printf("scheduled prompt sync done category=%s", category.Category) + } +} + +func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting { + if setting.Cron == "" { + setting.Cron = defaultPromptSyncCron + } + return setting +} diff --git a/service/prompts.go b/service/prompts.go index b744686..5f8d528 100644 --- a/service/prompts.go +++ b/service/prompts.go @@ -48,6 +48,13 @@ func DeletePrompt(id string) error { return repository.DeletePrompt(id) } +func DeletePrompts(ids []string) error { + if len(ids) == 0 { + return nil + } + return repository.DeletePrompts(ids) +} + func promptCategoryCodes(items []model.PromptCategory) []string { codes := []string{} for _, item := range items { diff --git a/service/settings.go b/service/settings.go index 5bca04a..1b9a3f2 100644 --- a/service/settings.go +++ b/service/settings.go @@ -32,6 +32,9 @@ func SaveSettings(settings model.Settings) (model.Settings, error) { settings = normalizeSettings(settings) keepPrivateAPIKeys(&settings, normalizeSettings(saved)) result, err := repository.SaveSettings(settings, now()) + if err == nil { + RefreshPromptSyncScheduler() + } return hidePrivateAPIKeys(result), err } @@ -72,6 +75,7 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting if setting.Channels == nil { setting.Channels = []model.ModelChannel{} } + setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync) for i := range setting.Channels { if setting.Channels[i].Protocol == "" { setting.Channels[i].Protocol = "openai" diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx index 5320af9..7b11bd7 100644 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ b/web/src/app/(admin)/admin/prompts/page.tsx @@ -10,12 +10,14 @@ import type { Prompt } from "@/services/api/prompts"; import { useAdminPrompts } from "./use-admin-prompts"; export default function AdminPromptsPage() { - const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts(); + const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt, deletePrompts } = useAdminPrompts(); const copyText = useCopyText(); const [form] = Form.useForm & { tagText?: string }>(); const [editingPrompt, setEditingPrompt] = useState | null>(null); const [detailPrompt, setDetailPrompt] = useState(null); const [deletingPrompt, setDeletingPrompt] = useState(null); + const [selectedPromptIds, setSelectedPromptIds] = useState([]); + const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false); const [isSyncOpen, setIsSyncOpen] = useState(false); const defaultCategory = categories[0]?.category || ""; const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category; @@ -32,6 +34,12 @@ export default function AdminPromptsPage() { setEditingPrompt(null); }; + const batchDeletePrompts = async () => { + await deletePrompts(selectedPromptIds); + setSelectedPromptIds([]); + setIsBatchDeleteOpen(false); + }; + const columns: ProColumns[] = [ { title: "封面", @@ -96,7 +104,12 @@ export default function AdminPromptsPage() { cardProps={{ variant: "borderless" }} headerTitle={提示词列表{total} 条} options={{ density: true, setting: true, reload: () => void refreshPrompts() }} - toolBarRender={() => [, ]} + rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }} + toolBarRender={() => [ + , + , + , + ]} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [10, 20, 50, 100], showTotal: (value) => `共 ${value} 条`, onChange: (nextPage, nextPageSize) => nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage) }} /> @@ -146,6 +159,10 @@ export default function AdminPromptsPage() { setDeletingPrompt(null)} onOk={async () => { if (!deletingPrompt) return; await deletePrompt(deletingPrompt.id); setDeletingPrompt(null); }} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。 + + setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> + 确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。 + ); } diff --git a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts index 86b8208..8193314 100644 --- a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts +++ b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { App } from "antd"; -import { deleteAdminPrompt, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin"; +import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin"; import type { Prompt } from "@/services/api/prompts"; import { useUserStore } from "@/stores/use-user-store"; @@ -71,6 +71,18 @@ export function useAdminPrompts() { }, }); + const batchDeleteMutation = useMutation({ + mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); + await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); + message.success("提示词已批量删除"); + }, + onError: (error) => { + message.error(error instanceof Error ? error.message : "批量删除失败"); + }, + }); + useEffect(() => { const error = categoriesQuery.error || promptsQuery.error; if (!error) return; @@ -101,7 +113,7 @@ export function useAdminPrompts() { page, pageSize, total: data?.total || 0, - isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending, + isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending, isSyncing: syncMutation.isPending, syncCategory: (category: string) => syncMutation.mutateAsync(category), searchPrompts: (value = keyword) => updateFilters({ keyword: value }), @@ -116,5 +128,6 @@ export function useAdminPrompts() { }, savePrompt: (prompt: Partial) => saveMutation.mutateAsync(prompt), deletePrompt: (id: string) => deleteMutation.mutateAsync(id), + deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids), }; } diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 342424c..0c48ee3 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -35,7 +35,7 @@ const emptySettings: AdminSettings = { allowCustomChannel: true, }, }, - private: { channels: [] }, + private: { channels: [], promptSync: { enabled: false, cron: "0 3 * * *" } }, }; const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; @@ -62,6 +62,7 @@ export default function AdminSettingsPage() { const [isSaving, setIsSaving] = useState(false); const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; const channelModels = useMemo(() => collectChannelModels(channels), [channels]); + const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); const activeMode = editorMode[activeTab]; const activeJsonText = jsonText[activeTab]; @@ -321,13 +322,19 @@ export default function AdminSettingsPage() { + + + + + + String(index)} + rowKey="_rowKey" pagination={false} - dataSource={channels} + dataSource={channelTableData} columns={[ { title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" }, { title: "协议", dataIndex: "protocol", width: 96, render: (value) => {value || "openai"} }, @@ -339,13 +346,13 @@ export default function AdminSettingsPage() { key: "actions", width: 220, align: "right", - render: (_, __, index) => ( + render: (_, item) => ( - - + +