From 82271c0dfa33d87210b287d9149fcfd06001a9d1 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:48:14 +0800 Subject: [PATCH 01/31] fix: update chat font variable --- frontend/app/globals.css | 2 +- frontend/app/layout.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 43857fa8..6a7a41ad 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1105,7 +1105,7 @@ } :root[data-chat-font="mono"] { - --font-chat: var(--font-jetbrains-mono); + --font-chat: var(--font-jetbrains-mono, "JetBrains Mono", var(--font-mono)); } :root[data-chat-font-weight="medium"] { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 78e1cd96..3e703868 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -73,9 +73,13 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + From d42a91b4eeccf68c44f76c6b2b8880a108a33cdb Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:27:42 +0800 Subject: [PATCH 02/31] refactor: update file processing timeouts and enhance batch response handling --- .../application/processing/service.go | 67 ++++++++++- .../processing/service_timeout_test.go | 77 +++++++++++++ .../internal/infra/extract/mineru/client.go | 38 +++--- .../infra/extract/mineru/client_test.go | 109 ++++++++++++++++++ 4 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 backend/internal/application/processing/service_timeout_test.go create mode 100644 backend/internal/infra/extract/mineru/client_test.go diff --git a/backend/internal/application/processing/service.go b/backend/internal/application/processing/service.go index 7ae8994a..0c2d0d7f 100644 --- a/backend/internal/application/processing/service.go +++ b/backend/internal/application/processing/service.go @@ -20,7 +20,7 @@ const ( DefaultExtractorVersion = "file-pipeline-v1" fileProcessingMaxRetries = 3 defaultProcessingPreview = 280 - fixedExtractTimeout = 60 * time.Second + defaultExtractTimeout = 60 * time.Second fixedEmbeddingTimeout = 5 * time.Minute failurePersistTimeout = 5 * time.Second ) @@ -200,7 +200,9 @@ func (s *Service) ProcessFile(ctx context.Context, userID uint, fileID string) e return nil } - runCtx, cancel := context.WithTimeout(ctx, fixedExtractTimeout+fixedEmbeddingTimeout) + cfg := s.snapshot() + extractTimeout := resolveProcessingExtractTimeout(cfg, fileObj.FileCategory) + runCtx, cancel := context.WithTimeout(ctx, extractTimeout+fixedEmbeddingTimeout) defer cancel() startedAt := time.Now() @@ -221,7 +223,7 @@ func (s *Service) ProcessFile(ctx context.Context, userID uint, fileID string) e return err } - extractCtx, extractCancel := context.WithTimeout(runCtx, fixedExtractTimeout) + extractCtx, extractCancel := context.WithTimeout(runCtx, extractTimeout) extractResult, extractErr := s.extractTextForProcessing(extractCtx, *fileObj) extractCancel() if extractErr != nil { @@ -642,6 +644,65 @@ func (s *Service) snapshot() config.Config { return s.cfg.Snapshot() } +func resolveProcessingExtractTimeout(cfg config.Config, fileCategory string) time.Duration { + primaryTimeout := resolvePrimaryExtractTimeout(cfg) + ocrTimeout := resolveOCRExtractTimeout(cfg) + + switch strings.ToLower(strings.TrimSpace(fileCategory)) { + case "image": + if cfg.ExtractImageOCREnabled { + return ocrTimeout + } + case "pdf": + if cfg.ExtractPDFOCRFallbackEnabled { + return primaryTimeout + ocrTimeout + } + } + return primaryTimeout +} + +func resolvePrimaryExtractTimeout(cfg config.Config) time.Duration { + timeoutSeconds := 0 + switch strings.ToLower(strings.TrimSpace(cfg.ExtractEngine)) { + case extraction.EngineTika: + timeoutSeconds = cfg.ExtractTikaTimeoutSeconds + case extraction.EngineDocling: + timeoutSeconds = cfg.ExtractDoclingTimeoutSeconds + case extraction.EngineMinerU: + timeoutSeconds = cfg.ExtractMinerUTimeoutSeconds + default: + timeoutSeconds = int(defaultExtractTimeout / time.Second) + } + if timeoutSeconds <= 0 { + return defaultExtractTimeout + } + return time.Duration(timeoutSeconds) * time.Second +} + +func resolveOCRExtractTimeout(cfg config.Config) time.Duration { + timeoutSeconds := 0 + switch strings.ToLower(strings.TrimSpace(cfg.ExtractOCREngine)) { + case extraction.OCREngineTesseract: + timeoutSeconds = cfg.ExtractTesseractOCRTimeoutSeconds + case extraction.OCREngineRapidOCR: + timeoutSeconds = cfg.ExtractRapidOCRTimeoutSeconds + case extraction.OCREnginePaddle: + timeoutSeconds = cfg.ExtractPaddleOCRTimeoutSeconds + case extraction.OCREngineTencent: + timeoutSeconds = cfg.ExtractTencentOCRTimeoutSeconds + case extraction.OCREngineAliyun: + timeoutSeconds = cfg.ExtractAliyunOCRTimeoutSeconds + case extraction.OCREngineLLM: + timeoutSeconds = cfg.ExtractLLMOCRTimeoutSeconds + default: + timeoutSeconds = int(defaultExtractTimeout / time.Second) + } + if timeoutSeconds <= 0 { + return defaultExtractTimeout + } + return time.Duration(timeoutSeconds) * time.Second +} + func (s *Service) version() string { if strings.TrimSpace(s.extractorVersion) == "" { return DefaultExtractorVersion diff --git a/backend/internal/application/processing/service_timeout_test.go b/backend/internal/application/processing/service_timeout_test.go new file mode 100644 index 00000000..d7ed954b --- /dev/null +++ b/backend/internal/application/processing/service_timeout_test.go @@ -0,0 +1,77 @@ +package processing + +import ( + "testing" + "time" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/extraction" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" +) + +func TestResolveProcessingExtractTimeoutUsesMinerUConfig(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineMinerU, + ExtractMinerUTimeoutSeconds: 180, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 180*time.Second { + t.Fatalf("expected MinerU timeout to be 180s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutFallsBackToDefault(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineMinerU, + ExtractMinerUTimeoutSeconds: 0, + } + + got := resolveProcessingExtractTimeout(cfg, "word") + if got != defaultExtractTimeout { + t.Fatalf("expected default timeout %s, got %s", defaultExtractTimeout, got) + } +} + +func TestResolveProcessingExtractTimeoutUsesImageOCRConfig(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineBuiltin, + ExtractImageOCREnabled: true, + ExtractOCREngine: extraction.OCREngineRapidOCR, + ExtractRapidOCRTimeoutSeconds: 90, + ExtractTesseractOCRTimeoutSeconds: 120, + } + + got := resolveProcessingExtractTimeout(cfg, "image") + if got != 90*time.Second { + t.Fatalf("expected image OCR timeout to be 90s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutAddsPDFOCRFallbackWindow(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineTika, + ExtractTikaTimeoutSeconds: 80, + ExtractPDFOCRFallbackEnabled: true, + ExtractOCREngine: extraction.OCREngineTesseract, + ExtractTesseractOCRTimeoutSeconds: 90, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 170*time.Second { + t.Fatalf("expected PDF extraction plus OCR fallback timeout to be 170s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutIgnoresOCRForPDFWhenFallbackDisabled(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineDocling, + ExtractDoclingTimeoutSeconds: 75, + ExtractOCREngine: extraction.OCREngineLLM, + ExtractLLMOCRTimeoutSeconds: 180, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 75*time.Second { + t.Fatalf("expected PDF timeout to use primary engine only, got %s", got) + } +} diff --git a/backend/internal/infra/extract/mineru/client.go b/backend/internal/infra/extract/mineru/client.go index f4807055..f893b5b3 100644 --- a/backend/internal/infra/extract/mineru/client.go +++ b/backend/internal/infra/extract/mineru/client.go @@ -369,10 +369,10 @@ func (c *Client) createBatch(ctx context.Context, req Request) (string, string, if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&parsed); err != nil { return "", "", fmt.Errorf("mineru_invalid_response") } - if strings.TrimSpace(parsed.Data.BatchID) == "" || len(parsed.Data.FileURLs) == 0 || strings.TrimSpace(parsed.Data.FileURLs[0].UploadURL) == "" { + if strings.TrimSpace(parsed.Data.BatchID) == "" || len(parsed.Data.FileURLs) == 0 || strings.TrimSpace(parsed.Data.FileURLs[0]) == "" { return "", "", fmt.Errorf("mineru_invalid_response") } - return strings.TrimSpace(parsed.Data.BatchID), strings.TrimSpace(parsed.Data.FileURLs[0].UploadURL), nil + return strings.TrimSpace(parsed.Data.BatchID), strings.TrimSpace(parsed.Data.FileURLs[0]), nil } func (c *Client) uploadFile(ctx context.Context, uploadURL string, absolutePath string) error { @@ -446,20 +446,27 @@ func (c *Client) pollBatch(ctx context.Context, batchID string) (string, error) } state := strings.ToLower(strings.TrimSpace(parsed.Data.State)) + var item batchResultItem + if len(parsed.Data.ExtractResult) > 0 { + item = parsed.Data.ExtractResult[0] + if itemState := strings.ToLower(strings.TrimSpace(item.State)); itemState != "" { + state = itemState + } + } switch state { case "done", "success", "completed": if len(parsed.Data.ExtractResult) == 0 { return "", fmt.Errorf("mineru_invalid_response") } - zipURL := strings.TrimSpace(parsed.Data.ExtractResult[0].FullZipURL) + zipURL := strings.TrimSpace(item.FullZipURL) if zipURL == "" { return "", fmt.Errorf("mineru_invalid_response") } return zipURL, nil case "failed", "error": detail := strings.TrimSpace(parsed.Data.ErrMsg) - if detail == "" && len(parsed.Data.ExtractResult) > 0 { - detail = strings.TrimSpace(parsed.Data.ExtractResult[0].ErrMsg) + if detail == "" { + detail = strings.TrimSpace(item.ErrMsg) } if detail == "" { return "", fmt.Errorf("mineru_failed") @@ -663,10 +670,8 @@ func awaitMultipartWriteError(errCh <-chan error) error { type batchCreateResponse struct { Data struct { - BatchID string `json:"batch_id"` - FileURLs []struct { - UploadURL string `json:"upload_url"` - } `json:"file_urls"` + BatchID string `json:"batch_id"` + FileURLs []string `json:"file_urls"` } `json:"data"` } @@ -689,11 +694,14 @@ func (r selfHostedResponse) firstMarkdown() string { type batchResultResponse struct { Data struct { - State string `json:"state"` - ErrMsg string `json:"err_msg"` - ExtractResult []struct { - ErrMsg string `json:"err_msg"` - FullZipURL string `json:"full_zip_url"` - } `json:"extract_result"` + State string `json:"state"` + ErrMsg string `json:"err_msg"` + ExtractResult []batchResultItem `json:"extract_result"` } `json:"data"` } + +type batchResultItem struct { + State string `json:"state"` + ErrMsg string `json:"err_msg"` + FullZipURL string `json:"full_zip_url"` +} diff --git a/backend/internal/infra/extract/mineru/client_test.go b/backend/internal/infra/extract/mineru/client_test.go new file mode 100644 index 00000000..4960b9cc --- /dev/null +++ b/backend/internal/infra/extract/mineru/client_test.go @@ -0,0 +1,109 @@ +package mineru + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestBatchCreateResponseParsesFileURLsAsStrings(t *testing.T) { + raw := []byte(`{ + "code": 0, + "msg": "ok", + "data": { + "batch_id": "batch-1", + "file_urls": ["https://example.com/upload"] + } + }`) + + var parsed batchCreateResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal batch create response: %v", err) + } + if parsed.Data.BatchID != "batch-1" { + t.Fatalf("unexpected batch id %q", parsed.Data.BatchID) + } + if len(parsed.Data.FileURLs) != 1 || parsed.Data.FileURLs[0] != "https://example.com/upload" { + t.Fatalf("unexpected file urls %#v", parsed.Data.FileURLs) + } +} + +func TestBatchResultResponseParsesExtractResultStateAndZipURL(t *testing.T) { + raw := []byte(`{ + "code": 0, + "msg": "ok", + "data": { + "state": "running", + "extract_result": [ + { + "state": "done", + "full_zip_url": "https://example.com/result.zip" + } + ] + } + }`) + + var parsed batchResultResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal batch result response: %v", err) + } + if len(parsed.Data.ExtractResult) != 1 { + t.Fatalf("unexpected extract result %#v", parsed.Data.ExtractResult) + } + if parsed.Data.ExtractResult[0].State != "done" { + t.Fatalf("unexpected item state %q", parsed.Data.ExtractResult[0].State) + } + if parsed.Data.ExtractResult[0].FullZipURL != "https://example.com/result.zip" { + t.Fatalf("unexpected full zip url %q", parsed.Data.ExtractResult[0].FullZipURL) + } +} + +func TestPollBatchUsesExtractResultState(t *testing.T) { + var calls int + client := &Client{ + baseURL: "https://mineru.example/api/v4", + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.Path != "/api/v4/extract-results/batch/batch-1" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "code": 0, + "msg": "ok", + "data": { + "state": "running", + "extract_result": [ + { + "state": "done", + "full_zip_url": "https://example.com/result.zip" + } + ] + } + }`)), + }, nil + })}, + } + + zipURL, err := client.pollBatch(context.Background(), "batch-1") + if err != nil { + t.Fatalf("poll batch: %v", err) + } + if zipURL != "https://example.com/result.zip" { + t.Fatalf("unexpected zip url %q", zipURL) + } + if calls != 1 { + t.Fatalf("expected one poll call, got %d", calls) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} From 485f8ec112303031b4b28961a14278c493832196 Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:42:18 +0800 Subject: [PATCH 03/31] feat: add expand and collapse functionality for recent conversations in navigation menu --- .../components/navigation/nav-recents.tsx | 232 +++++++++++------- frontend/i18n/messages/en-US/recent.json | 2 + frontend/i18n/messages/zh-CN/recent.json | 2 + frontend/next-env.d.ts | 2 +- 4 files changed, 150 insertions(+), 88 deletions(-) diff --git a/frontend/features/layouts/components/navigation/nav-recents.tsx b/frontend/features/layouts/components/navigation/nav-recents.tsx index a7b9b487..bd8214cc 100644 --- a/frontend/features/layouts/components/navigation/nav-recents.tsx +++ b/frontend/features/layouts/components/navigation/nav-recents.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useRouter } from "next/navigation" -import { Star } from "lucide-react" +import { ChevronDown, Star } from "lucide-react" import { useTranslations } from "next-intl" import { @@ -15,6 +15,10 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" +import { + Collapsible, + CollapsibleContent, +} from "@/components/ui/collapsible" import { Spinner } from "@/components/ui/spinner" import { SidebarGroup, @@ -43,6 +47,7 @@ import { useLoadMoreSentinel } from "@/shared/hooks/use-load-more-sentinel" import { cn } from "@/lib/utils" const RECENT_SKELETON_WIDTHS = ["74%", "61%", "69%", "57%", "72%"] as const +const RECENTS_OPEN_STORAGE_KEY = "deeix.sidebar.recents.open" export function NavRecents() { const t = useTranslations("recent") @@ -76,16 +81,46 @@ export function NavRecents() { const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const [autoRenamingPublicID, setAutoRenamingPublicID] = React.useState(null) + const [recentsOpen, setRecentsOpen] = React.useState(true) + const [recentsOpenHydrated, setRecentsOpenHydrated] = React.useState(false) const loadMoreRef = React.useRef(null) const listContainerRef = React.useRef(null) const deleteFilesID = React.useId() + const recentsContentID = React.useId() const onExport = useChatConversationExport({ successMessage: t("exported"), failureMessage: t("exportFailed"), }) + React.useEffect(() => { + try { + const stored = window.localStorage.getItem(RECENTS_OPEN_STORAGE_KEY) + if (stored === "true") { + setRecentsOpen(true) + } else if (stored === "false") { + setRecentsOpen(false) + } + } catch { + // Keep the default open state when localStorage is unavailable. + } finally { + setRecentsOpenHydrated(true) + } + }, []) + + React.useEffect(() => { + if (!recentsOpenHydrated) { + return + } + + try { + window.localStorage.setItem(RECENTS_OPEN_STORAGE_KEY, recentsOpen ? "true" : "false") + } catch { + // Ignore storage failures; the current in-memory state still controls the UI. + } + }, [recentsOpen, recentsOpenHydrated]) + useLoadMoreSentinel({ - enabled: hasMore && !loadingInitial && !loadMoreFailed, + enabled: recentsOpenHydrated && recentsOpen && hasMore && !loadingInitial && !loadMoreFailed, targetRef: loadMoreRef, onLoadMore: loadMore, }) @@ -182,7 +217,7 @@ export function NavRecents() { ) useLayoutSidebarListFlip(listContainerRef, { - enabled: Boolean(transferringStarPublicID), + enabled: recentsOpen && Boolean(transferringStarPublicID), signature: visibleItemsSignature, excludeKey: transferringStarPublicID, }) @@ -190,95 +225,118 @@ export function NavRecents() { return ( <>
- - {t("title")} -
- } - className="min-h-0" + + + - - {visibleRecentItems.length === 0 ? ( -
  • - {t("empty")} -
  • - ) : null} + +
    + +
    + } + className="min-h-0" + > + + {visibleRecentItems.length === 0 ? ( +
  • + {t("empty")} +
  • + ) : null} - {visibleRecentItems.map((item) => { - const title = item.title || t("untitled") - const publicID = item.publicID + {visibleRecentItems.map((item) => { + const title = item.title || t("untitled") + const publicID = item.publicID - return ( - onToggleStar(targetPublicID, !item.isStarred), - }} - projectMenu={{ - label: t("row.moveToProject"), - unassignedLabel: t("projects.unassigned"), - currentProjectID: item.projectID, - projects, - onSelect: (targetPublicID, projectID) => { - void setProjectByPublicID(targetPublicID, projectID) - }, - }} - isTransferring={transferringStarPublicID === publicID} - onRename={onRename} - isRenaming={renameTarget?.publicID === publicID} - renameValue={renameTarget?.publicID === publicID ? renameValue : title} - onRenameValueChange={setRenameValue} - onRenameCommit={onRenameCommit} - onRenameCancel={onRenameCancel} - onAutoRename={onAutoRename} - isAutoRenaming={autoRenamingPublicID === publicID} - onArchive={onArchive} - onShare={onShare} - onExport={onExport} - onDelete={onDelete} - onNavigate={isMobile ? () => setOpenMobile(false) : undefined} - menuTriggerID={`recent-item-menu-trigger-${publicID}`} - /> - ) - })} + return ( + onToggleStar(targetPublicID, !item.isStarred), + }} + projectMenu={{ + label: t("row.moveToProject"), + unassignedLabel: t("projects.unassigned"), + currentProjectID: item.projectID, + projects, + onSelect: (targetPublicID, projectID) => { + void setProjectByPublicID(targetPublicID, projectID) + }, + }} + isTransferring={transferringStarPublicID === publicID} + onRename={onRename} + isRenaming={renameTarget?.publicID === publicID} + renameValue={renameTarget?.publicID === publicID ? renameValue : title} + onRenameValueChange={setRenameValue} + onRenameCommit={onRenameCommit} + onRenameCancel={onRenameCancel} + onAutoRename={onAutoRename} + isAutoRenaming={autoRenamingPublicID === publicID} + onArchive={onArchive} + onShare={onShare} + onExport={onExport} + onDelete={onDelete} + onNavigate={isMobile ? () => setOpenMobile(false) : undefined} + menuTriggerID={`recent-item-menu-trigger-${publicID}`} + /> + ) + })} - {loadingMore ? ( -
  • - - {t("loadingMore")} -
  • - ) : null} - {hasMore && !loadMoreFailed ? ( -
  • + + {t("loadingMore")} +
  • + ) : null} + {hasMore && !loadMoreFailed ? ( +
  • - {t("loadMoreFailed")} - -
  • - ) : null} -
    -
    -
    -
    + {loadMoreFailed ? ( +
  • + {t("loadMoreFailed")} + +
  • + ) : null} + +
    +
    + +
    +
    /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From f48cd998c9a1ff0d57fed9cf0aaacea055190cfd Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:48:24 +0800 Subject: [PATCH 04/31] feat: persist sidebar state in localStorage --- frontend/components/ui/sidebar.tsx | 38 ++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx index 3a96e5d6..5dd4c57b 100644 --- a/frontend/components/ui/sidebar.tsx +++ b/frontend/components/ui/sidebar.tsx @@ -28,6 +28,7 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_STORAGE_KEY = "deeix.sidebar.open" const SIDEBAR_WIDTH = "17.96875rem" const SIDEBAR_WIDTH_MOBILE = "17.96875rem" const SIDEBAR_WIDTH_ICON = "3rem" @@ -59,6 +60,30 @@ function isCompactSidebarViewport() { return typeof window !== "undefined" && window.innerWidth < SIDEBAR_AUTO_COLLAPSE_BREAKPOINT } +function readSidebarStorageOpen() { + if (typeof window === "undefined") { + return null + } + + try { + const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY) + if (stored === "true") { + return true + } + if (stored === "false") { + return false + } + } catch { + // Keep the default state when localStorage is unavailable. + } + + return null +} + +function resolveSidebarPreferredOpen(defaultOpen: boolean) { + return readSidebarStorageOpen() ?? defaultOpen +} + function useCompactSidebarViewport() { const [isCompact, setIsCompact] = React.useState(isCompactSidebarViewport) @@ -91,11 +116,11 @@ function SidebarProvider({ const isCompactViewport = useCompactSidebarViewport() const [openMobile, setOpenMobile] = React.useState(false) const wasCompactViewportRef = React.useRef(isCompactSidebarViewport()) - const autoCollapsedRef = React.useRef(defaultOpen && isCompactSidebarViewport()) + const autoCollapsedRef = React.useRef(resolveSidebarPreferredOpen(defaultOpen) && isCompactSidebarViewport()) // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(() => defaultOpen && !isCompactSidebarViewport()) + const [_open, _setOpen] = React.useState(() => resolveSidebarPreferredOpen(defaultOpen) && !isCompactSidebarViewport()) const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { @@ -109,6 +134,11 @@ function SidebarProvider({ // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + try { + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, openState ? "true" : "false") + } catch { + // Ignore storage failures; the current in-memory state still controls the UI. + } }, [setOpenProp, open] ) @@ -133,7 +163,7 @@ function SidebarProvider({ return } - if (!leftCompactViewport || !autoCollapsedRef.current || !defaultOpen) { + if (!leftCompactViewport || !autoCollapsedRef.current) { return } @@ -144,7 +174,7 @@ function SidebarProvider({ } _setOpen(true) - }, [defaultOpen, isCompactViewport, open, setOpenProp]) + }, [isCompactViewport, open, setOpenProp]) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { From 0fa65f1b33e7a6f5989b857f815c15e1bf076d61 Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:01:18 +0800 Subject: [PATCH 05/31] feat: auto save user avatar after upload --- .../sections/general/general-profile.tsx | 14 +++++-- .../sections/general/settings-general.tsx | 40 +++++++++++++++++-- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/frontend/features/settings/components/sections/general/general-profile.tsx b/frontend/features/settings/components/sections/general/general-profile.tsx index 9d3884f1..7c691192 100644 --- a/frontend/features/settings/components/sections/general/general-profile.tsx +++ b/frontend/features/settings/components/sections/general/general-profile.tsx @@ -239,13 +239,21 @@ export function GeneralProfileSection({ - diff --git a/frontend/features/settings/components/sections/general/settings-general.tsx b/frontend/features/settings/components/sections/general/settings-general.tsx index 4a4aaf6a..47324bff 100644 --- a/frontend/features/settings/components/sections/general/settings-general.tsx +++ b/frontend/features/settings/components/sections/general/settings-general.tsx @@ -271,10 +271,42 @@ export function SettingsGeneral() { setAvatarDialogOpen(true); }, [draft.avatarUrl]); - const handleSaveAvatarDialog = React.useCallback(() => { - setDraft((current) => ({ ...current, avatarUrl: avatarDialogValue.trim() })); - setAvatarDialogOpen(false); - }, [avatarDialogValue]); + const handleSaveAvatarDialog = React.useCallback(async () => { + if (saving || avatarUploading) { + return; + } + + const nextAvatarURL = avatarDialogValue.trim(); + if (nextAvatarURL === initialDraft.avatarUrl) { + setAvatarDialogOpen(false); + return; + } + + try { + setSaving(true); + const nextViewer = await patchMe(accessToken, { avatarURL: nextAvatarURL }); + const nextInitialDraft = createDraftFromUser(nextViewer); + setViewer(nextViewer); + setDraft((current) => ({ ...current, avatarUrl: nextInitialDraft.avatarUrl })); + setInitialDraft(nextInitialDraft); + setAvatarUploadPreview((current) => { + const savedFileID = parseFileAvatarID(nextInitialDraft.avatarUrl); + if (current && current.fileID === savedFileID) { + return null; + } + return current; + }); + dispatchUserProfileUpdated(nextViewer); + setAvatarDialogOpen(false); + toast.success(t("generalPage.toast.profileUpdated")); + } catch (error) { + toast.error(t("generalPage.toast.saveProfileFailed"), { + description: resolveLocalizedErrorMessage(error), + }); + } finally { + setSaving(false); + } + }, [accessToken, avatarDialogValue, avatarUploading, initialDraft.avatarUrl, saving, t]); const handleCycleGeneratedAvatar = React.useCallback(() => { setAvatarDialogValue(createGeneratedGithubAvatarRef(generateAvatarVariant())); From c4686c6c6bc4f846f3d84bf1fadb869535f74e64 Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:09:48 +0800 Subject: [PATCH 06/31] fix: update Next.js route types path in next-env.d.ts to resolve import issue --- frontend/next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 0997b8669c14893029cd1d6f1a7fbd57a97aa5d2 Mon Sep 17 00:00:00 2001 From: Noxiven <41963680+Noxiven@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:31:44 +0800 Subject: [PATCH 07/31] feat: add collapsible functionality for starred conversations --- .../components/navigation/nav-starred.tsx | 222 +++++++++++------- frontend/i18n/messages/en-US/recent.json | 2 + frontend/i18n/messages/zh-CN/recent.json | 2 + 3 files changed, 144 insertions(+), 82 deletions(-) diff --git a/frontend/features/layouts/components/navigation/nav-starred.tsx b/frontend/features/layouts/components/navigation/nav-starred.tsx index 4b13916a..b75c0802 100644 --- a/frontend/features/layouts/components/navigation/nav-starred.tsx +++ b/frontend/features/layouts/components/navigation/nav-starred.tsx @@ -3,10 +3,14 @@ import * as React from "react" import { useRouter } from "next/navigation" import { motion } from "motion/react" -import { StarOff } from "lucide-react" +import { ChevronDown, StarOff } from "lucide-react" import { useTranslations } from "next-intl" import { List } from "@/components/animate-ui/icons/list" +import { + Collapsible, + CollapsibleContent, +} from "@/components/ui/collapsible" import { AlertDialog, AlertDialogAction, @@ -50,6 +54,7 @@ import { cn } from "@/lib/utils" const STARRED_SKELETON_WIDTHS = ["71%", "59%", "66%", "54%", "70%"] as const const MAX_VISIBLE_STARRED = 5 +const STARRED_OPEN_STORAGE_KEY = "deeix.sidebar.starred.open" function toSidebarConversationItem(item: ConversationDTO, untitled: string): SidebarConversationItemModel { return { @@ -93,8 +98,11 @@ export function NavStarred() { const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const [autoRenamingPublicID, setAutoRenamingPublicID] = React.useState(null) + const [starredOpen, setStarredOpen] = React.useState(true) + const [starredOpenHydrated, setStarredOpenHydrated] = React.useState(false) const listContainerRef = React.useRef(null) const deleteFilesID = React.useId() + const starredContentID = React.useId() const onExport = useChatConversationExport({ successMessage: t("exported"), failureMessage: t("exportFailed"), @@ -120,11 +128,38 @@ export function NavStarred() { const showInitialSkeleton = loadingInitial && starredConversationItems.length === 0 useLayoutSidebarListFlip(listContainerRef, { - enabled: Boolean(transferringStarPublicID), + enabled: starredOpen && Boolean(transferringStarPublicID), signature: visibleStarredSignature, excludeKey: transferringStarPublicID, }) + React.useEffect(() => { + try { + const stored = window.localStorage.getItem(STARRED_OPEN_STORAGE_KEY) + if (stored === "true") { + setStarredOpen(true) + } else if (stored === "false") { + setStarredOpen(false) + } + } catch { + // Keep the default open state when localStorage is unavailable. + } finally { + setStarredOpenHydrated(true) + } + }, []) + + React.useEffect(() => { + if (!starredOpenHydrated) { + return + } + + try { + window.localStorage.setItem(STARRED_OPEN_STORAGE_KEY, starredOpen ? "true" : "false") + } catch { + // Ignore storage failures; the current in-memory state still controls the UI. + } + }, [starredOpen, starredOpenHydrated]) + React.useEffect(() => { if (!showAllStarredDialog) { setDialogLoading(false) @@ -261,90 +296,113 @@ export function NavStarred() { animate={{ height: "auto", opacity: 1, y: 0 }} transition={SIDEBAR_OVERFLOW_ROW_TRANSITION} > - - {t("starred")} -
    - } - className="min-h-0" + + + - - {visibleStarredItems.map((item) => ( - - conversation.publicID === item.publicID && - conversation.shareStatus === "active" && - Boolean(conversation.shareID?.trim()), - ), - }} - active={activeConversationID === item.publicID} - isTransferring={transferringStarPublicID === item.publicID} - starAction={{ - label: t("row.unstar"), - icon: StarOff, - onSelect: onUnstar, - }} - projectMenu={{ - label: t("row.moveToProject"), - unassignedLabel: t("projects.unassigned"), - currentProjectID: starredItems.find((conversation) => conversation.publicID === item.publicID)?.projectID, - projects, - onSelect: (targetPublicID, projectID) => { - void setProjectByPublicID(targetPublicID, projectID) - }, - }} - onRename={onRename} - isRenaming={renameTarget?.publicID === item.publicID} - renameValue={renameTarget?.publicID === item.publicID ? renameValue : item.title} - onRenameValueChange={setRenameValue} - onRenameCommit={onRenameCommit} - onRenameCancel={onRenameCancel} - onAutoRename={onAutoRename} - isAutoRenaming={autoRenamingPublicID === item.publicID} - onArchive={onArchive} - onShare={onShare} - onExport={onExport} - onDelete={onDelete} - onNavigate={isMobile ? () => setOpenMobile(false) : undefined} - menuTriggerID={`starred-item-menu-trigger-${item.publicID}`} - /> - ))} - - setStarredOpen((open) => !open)} + > + {t("starred")} + + + + +
    + } + className="min-h-0" > - { - if (hasOverflowButton) { - setShowAllStarredDialog(true) - } - }} - > - - {t("allConversations")} - - - - -
    -
    + + {visibleStarredItems.map((item) => ( + + conversation.publicID === item.publicID && + conversation.shareStatus === "active" && + Boolean(conversation.shareID?.trim()), + ), + }} + active={activeConversationID === item.publicID} + isTransferring={transferringStarPublicID === item.publicID} + starAction={{ + label: t("row.unstar"), + icon: StarOff, + onSelect: onUnstar, + }} + projectMenu={{ + label: t("row.moveToProject"), + unassignedLabel: t("projects.unassigned"), + currentProjectID: starredItems.find((conversation) => conversation.publicID === item.publicID)?.projectID, + projects, + onSelect: (targetPublicID, projectID) => { + void setProjectByPublicID(targetPublicID, projectID) + }, + }} + onRename={onRename} + isRenaming={renameTarget?.publicID === item.publicID} + renameValue={renameTarget?.publicID === item.publicID ? renameValue : item.title} + onRenameValueChange={setRenameValue} + onRenameCommit={onRenameCommit} + onRenameCancel={onRenameCancel} + onAutoRename={onAutoRename} + isAutoRenaming={autoRenamingPublicID === item.publicID} + onArchive={onArchive} + onShare={onShare} + onExport={onExport} + onDelete={onDelete} + onNavigate={isMobile ? () => setOpenMobile(false) : undefined} + menuTriggerID={`starred-item-menu-trigger-${item.publicID}`} + /> + ))} + + + { + if (hasOverflowButton) { + setShowAllStarredDialog(true) + } + }} + > + + {t("allConversations")} + + + +
    +
    + +
    + Date: Tue, 16 Jun 2026 22:14:22 +0800 Subject: [PATCH 08/31] feat: implement collapsible sections for projects in navigation --- .../components/navigation/nav-projects.tsx | 166 +++++++++++++----- .../components/navigation/nav-recents.tsx | 6 +- .../components/navigation/nav-starred.tsx | 6 +- frontend/i18n/messages/en-US/recent.json | 2 + frontend/i18n/messages/zh-CN/recent.json | 4 +- 5 files changed, 133 insertions(+), 51 deletions(-) diff --git a/frontend/features/layouts/components/navigation/nav-projects.tsx b/frontend/features/layouts/components/navigation/nav-projects.tsx index 1f96e81b..606ee774 100644 --- a/frontend/features/layouts/components/navigation/nav-projects.tsx +++ b/frontend/features/layouts/components/navigation/nav-projects.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { AnimatePresence, motion, type Transition } from "motion/react" -import { PencilLine, Star, StarOff, Trash } from "lucide-react" +import { ChevronDown, PencilLine, Star, StarOff, Trash } from "lucide-react" import { useTranslations } from "next-intl" import { Ellipsis } from "@/components/animate-ui/icons/ellipsis" @@ -22,6 +22,10 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" +import { + Collapsible, + CollapsibleContent, +} from "@/components/ui/collapsible" import { Dialog, DialogContent, @@ -108,7 +112,8 @@ const PROJECT_TREE_ACCORDION_MASK_STYLE = { overflow: "hidden", } satisfies React.CSSProperties const PROJECT_CREATE_ACTION_CLASS = - "static size-7 shrink-0 opacity-100 transition-[background-color,color,opacity,transform] duration-150" + "static size-7 shrink-0 opacity-0 transition-[background-color,color,opacity,transform] duration-150 group-hover/project-create:opacity-100 group-focus-within/project-create:opacity-100" +const PROJECTS_OPEN_STORAGE_KEY = "deeix.sidebar.projects.open" type ProjectFolderIconHandle = { startAnimation: () => void @@ -118,26 +123,53 @@ type ProjectFolderIconHandle = { function ProjectGroupHeader({ title, createLabel, + contentID, + open, onCreate, + onOpenChange, + toggleLabel, }: { title: string createLabel: string + contentID: string + open: boolean onCreate: () => void + onOpenChange: (open: boolean) => void + toggleLabel: string }) { const [createHovered, setCreateHovered] = React.useState(false) return ( -
    - {title} +
    + + + setCreateHovered(true)} onMouseLeave={() => setCreateHovered(false)} onClick={onCreate} > - +
    ) @@ -304,6 +336,8 @@ export function NavProjects() { const [hoveredProjectCreateID, setHoveredProjectCreateID] = React.useState(null) const [hoveredProjectRowID, setHoveredProjectRowID] = React.useState(null) const [focusedProjectRowID, setFocusedProjectRowID] = React.useState(null) + const [projectsOpen, setProjectsOpen] = React.useState(true) + const [projectsOpenHydrated, setProjectsOpenHydrated] = React.useState(false) const projectConversationStateRef = React.useRef(projectConversationState) const expandedProjectIDsRef = React.useRef(expandedProjectIDs) const activeConversationProjectID = React.useMemo( @@ -313,6 +347,7 @@ export function NavProjects() { const deleteProjectConversationsID = React.useId() const deleteProjectFilesID = React.useId() const deleteConversationFilesID = React.useId() + const projectsContentID = React.useId() const onExportConversation = useChatConversationExport({ successMessage: tRecent("exported"), failureMessage: tRecent("exportFailed"), @@ -330,6 +365,33 @@ export function NavProjects() { setExpandedProjectIDs(next) }, []) + React.useEffect(() => { + try { + const stored = window.localStorage.getItem(PROJECTS_OPEN_STORAGE_KEY) + if (stored === "true") { + setProjectsOpen(true) + } else if (stored === "false") { + setProjectsOpen(false) + } + } catch { + // Keep the default open state when localStorage is unavailable. + } finally { + setProjectsOpenHydrated(true) + } + }, []) + + React.useEffect(() => { + if (!projectsOpenHydrated) { + return + } + + try { + window.localStorage.setItem(PROJECTS_OPEN_STORAGE_KEY, projectsOpen ? "true" : "false") + } catch { + // Ignore storage failures; the current in-memory state still controls the UI. + } + }, [projectsOpen, projectsOpenHydrated]) + const closeDraft = React.useCallback(() => { setDraft(null) }, []) @@ -661,14 +723,22 @@ export function NavProjects() { return ( <>
    - - setDraft({ name: "", systemPrompt: "" })} - /> -
    {t("empty")}
    -
    + + + setDraft({ name: "", systemPrompt: "" })} + onOpenChange={setProjectsOpen} + toggleLabel={projectsOpen ? t("collapseSection") : t("expandSection")} + /> + +
    {t("empty")}
    +
    +
    +
    !open && closeDraft()} onSubmit={commitDraft} /> @@ -678,31 +748,37 @@ export function NavProjects() { return ( <>
    - - setDraft({ name: "", systemPrompt: "" })} - /> - - {projects.map((project) => { - const expanded = expandedProjectIDs.has(project.publicID) - const conversationState = projectConversationState[project.publicID] - const conversationLoading = expanded && (!conversationState || conversationState.loading) - const hasActiveChild = Boolean(conversationState?.items.some((item) => item.publicID === activeConversationID)) - const active = - ((pathname === "/recent" || pathname === "/chat") && activeProjectID === project.publicID) || - activeConversationProjectID === project.publicID || - hasActiveChild - const rowHovered = hoveredProjectRowID === project.publicID - const rowFocused = focusedProjectRowID === project.publicID - const createHovered = hoveredProjectCreateID === project.publicID - const menuHovered = hoveredProjectMenuID === project.publicID - const menuOpen = openProjectMenuID === project.publicID - const showProjectActions = rowHovered || rowFocused || menuHovered || menuOpen - const projectConversationContentID = `sidebar-project-${project.publicID}-conversations` - return ( - + + + setDraft({ name: "", systemPrompt: "" })} + onOpenChange={setProjectsOpen} + toggleLabel={projectsOpen ? t("collapseSection") : t("expandSection")} + /> + + + {projects.map((project) => { + const expanded = expandedProjectIDs.has(project.publicID) + const conversationState = projectConversationState[project.publicID] + const conversationLoading = expanded && (!conversationState || conversationState.loading) + const hasActiveChild = Boolean(conversationState?.items.some((item) => item.publicID === activeConversationID)) + const active = + ((pathname === "/recent" || pathname === "/chat") && activeProjectID === project.publicID) || + activeConversationProjectID === project.publicID || + hasActiveChild + const rowHovered = hoveredProjectRowID === project.publicID + const rowFocused = focusedProjectRowID === project.publicID + const createHovered = hoveredProjectCreateID === project.publicID + const menuHovered = hoveredProjectMenuID === project.publicID + const menuOpen = openProjectMenuID === project.publicID + const showProjectActions = rowHovered || rowFocused || menuHovered || menuOpen + const projectConversationContentID = `sidebar-project-${project.publicID}-conversations` + return ( +
    setFocusedProjectRowID(project.publicID)} @@ -859,11 +935,13 @@ export function NavProjects() { ) : null} - - ) - })} - - + + ) + })} + + + +
    !open && closeDraft()} onSubmit={commitDraft} /> diff --git a/frontend/features/layouts/components/navigation/nav-recents.tsx b/frontend/features/layouts/components/navigation/nav-recents.tsx index bd8214cc..e8caebee 100644 --- a/frontend/features/layouts/components/navigation/nav-recents.tsx +++ b/frontend/features/layouts/components/navigation/nav-recents.tsx @@ -229,7 +229,7 @@ export function NavRecents() {
    - +
    @@ -2102,7 +2102,7 @@ export function AdminBillingPage() { - +

    {t("toolPricing.nativeToolCount", { count: nativeToolPricing.length })}

    @@ -2167,7 +2167,7 @@ export function AdminBillingPage() {

    {t("toolPricing.defaultPriceDescription")}

    {t("toolPricing.note")}

    -
    + ) : null} {field.key === "rate_limit_enabled" ? ( - + {rateLimitFields.map((rateLimitField) => { @@ -466,10 +466,10 @@ export function AdminLoginSettingsPage() { })} - + ) : null} {field.key === "turnstile_registration_enabled" ? ( - + {turnstileFields.map((turnstileField) => { @@ -488,7 +488,7 @@ export function AdminLoginSettingsPage() { })} - + ) : null} ); diff --git a/frontend/features/admin/components/sections/tools/admin-tools.tsx b/frontend/features/admin/components/sections/tools/admin-tools.tsx index c3a5a799..9c277aa6 100644 --- a/frontend/features/admin/components/sections/tools/admin-tools.tsx +++ b/frontend/features/admin/components/sections/tools/admin-tools.tsx @@ -6,7 +6,7 @@ import { useLocale, useTranslations } from "next-intl"; import { toast } from "sonner"; import { SettingsFieldEditor } from "../shared/settings-runtime-panel"; -import { SettingsCollapsibleContent } from "../shared/settings-collapsible-content"; +import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -690,7 +690,7 @@ export function AdminToolsPage() { /> ) : null} - + {mcpRuntimeFields.map((field, index) => { const id = toolFieldID(field); return ( @@ -705,10 +705,10 @@ export function AdminToolsPage() { ); })} - + - +
    @@ -857,7 +857,7 @@ export function AdminToolsPage() { loading={serversLoading || actionServerID !== null} /> - + !open && setToolSheetServerID(null)}> diff --git a/frontend/features/layouts/components/navigation/nav-projects.tsx b/frontend/features/layouts/components/navigation/nav-projects.tsx index 606ee774..6e9d1e44 100644 --- a/frontend/features/layouts/components/navigation/nav-projects.tsx +++ b/frontend/features/layouts/components/navigation/nav-projects.tsx @@ -24,7 +24,6 @@ import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Collapsible, - CollapsibleContent, } from "@/components/ui/collapsible" import { Dialog, @@ -59,6 +58,7 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/chat-share-dialog" +import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content" import { useChatConversationExport } from "@/features/chat/hooks/use-chat-conversation-export" import { useChatSession } from "@/features/chat/context/chat-session-context" import { DeleteFilesOption } from "@/shared/components/delete-files-option" @@ -74,6 +74,7 @@ import { sortByUpdatedAtDesc, upsertByPublicID, removeByPublicID } from "@/featu import { listConversations } from "@/shared/api/conversation" import type { ConversationDTO } from "@/shared/api/conversation.types" import { resolveAccessToken } from "@/shared/auth/resolve-access-token" +import { useStoredBoolean } from "@/shared/hooks/use-stored-boolean" import { cn } from "@/lib/utils" type ProjectDraft = { @@ -336,8 +337,7 @@ export function NavProjects() { const [hoveredProjectCreateID, setHoveredProjectCreateID] = React.useState(null) const [hoveredProjectRowID, setHoveredProjectRowID] = React.useState(null) const [focusedProjectRowID, setFocusedProjectRowID] = React.useState(null) - const [projectsOpen, setProjectsOpen] = React.useState(true) - const [projectsOpenHydrated, setProjectsOpenHydrated] = React.useState(false) + const [projectsOpen, setProjectsOpen] = useStoredBoolean(PROJECTS_OPEN_STORAGE_KEY, true) const projectConversationStateRef = React.useRef(projectConversationState) const expandedProjectIDsRef = React.useRef(expandedProjectIDs) const activeConversationProjectID = React.useMemo( @@ -365,33 +365,6 @@ export function NavProjects() { setExpandedProjectIDs(next) }, []) - React.useEffect(() => { - try { - const stored = window.localStorage.getItem(PROJECTS_OPEN_STORAGE_KEY) - if (stored === "true") { - setProjectsOpen(true) - } else if (stored === "false") { - setProjectsOpen(false) - } - } catch { - // Keep the default open state when localStorage is unavailable. - } finally { - setProjectsOpenHydrated(true) - } - }, []) - - React.useEffect(() => { - if (!projectsOpenHydrated) { - return - } - - try { - window.localStorage.setItem(PROJECTS_OPEN_STORAGE_KEY, projectsOpen ? "true" : "false") - } catch { - // Ignore storage failures; the current in-memory state still controls the UI. - } - }, [projectsOpen, projectsOpenHydrated]) - const closeDraft = React.useCallback(() => { setDraft(null) }, []) @@ -734,9 +707,9 @@ export function NavProjects() { onOpenChange={setProjectsOpen} toggleLabel={projectsOpen ? t("collapseSection") : t("expandSection")} /> - +
    {t("empty")}
    -
    +
    @@ -759,7 +732,7 @@ export function NavProjects() { onOpenChange={setProjectsOpen} toggleLabel={projectsOpen ? t("collapseSection") : t("expandSection")} /> - + {projects.map((project) => { const expanded = expandedProjectIDs.has(project.publicID) @@ -939,7 +912,7 @@ export function NavProjects() { ) })} - +
    diff --git a/frontend/features/layouts/components/navigation/nav-recents.tsx b/frontend/features/layouts/components/navigation/nav-recents.tsx index e8caebee..4c59739a 100644 --- a/frontend/features/layouts/components/navigation/nav-recents.tsx +++ b/frontend/features/layouts/components/navigation/nav-recents.tsx @@ -17,7 +17,6 @@ import { } from "@/components/ui/alert-dialog" import { Collapsible, - CollapsibleContent, } from "@/components/ui/collapsible" import { Spinner } from "@/components/ui/spinner" import { @@ -35,6 +34,7 @@ import { } from "@/features/chat/components/sections/chat-share-dialog" import { useChatConversationExport } from "@/features/chat/hooks/use-chat-conversation-export" import { DeleteFilesOption } from "@/shared/components/delete-files-option" +import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content" import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences" import { useLayoutActiveConversation } from "@/features/layouts/hooks/use-layout-active-conversation" import { useLayoutSidebarListFlip } from "@/features/layouts/hooks/use-layout-sidebar-list-flip" @@ -44,6 +44,7 @@ import type { } from "@/features/layouts/types/navigation" import { useSidebarRecents } from "@/features/recent/context/sidebar-recents-context" import { useLoadMoreSentinel } from "@/shared/hooks/use-load-more-sentinel" +import { useStoredBoolean } from "@/shared/hooks/use-stored-boolean" import { cn } from "@/lib/utils" const RECENT_SKELETON_WIDTHS = ["74%", "61%", "69%", "57%", "72%"] as const @@ -81,8 +82,7 @@ export function NavRecents() { const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const [autoRenamingPublicID, setAutoRenamingPublicID] = React.useState(null) - const [recentsOpen, setRecentsOpen] = React.useState(true) - const [recentsOpenHydrated, setRecentsOpenHydrated] = React.useState(false) + const [recentsOpen, setRecentsOpen] = useStoredBoolean(RECENTS_OPEN_STORAGE_KEY, true) const loadMoreRef = React.useRef(null) const listContainerRef = React.useRef(null) const deleteFilesID = React.useId() @@ -92,35 +92,8 @@ export function NavRecents() { failureMessage: t("exportFailed"), }) - React.useEffect(() => { - try { - const stored = window.localStorage.getItem(RECENTS_OPEN_STORAGE_KEY) - if (stored === "true") { - setRecentsOpen(true) - } else if (stored === "false") { - setRecentsOpen(false) - } - } catch { - // Keep the default open state when localStorage is unavailable. - } finally { - setRecentsOpenHydrated(true) - } - }, []) - - React.useEffect(() => { - if (!recentsOpenHydrated) { - return - } - - try { - window.localStorage.setItem(RECENTS_OPEN_STORAGE_KEY, recentsOpen ? "true" : "false") - } catch { - // Ignore storage failures; the current in-memory state still controls the UI. - } - }, [recentsOpen, recentsOpenHydrated]) - useLoadMoreSentinel({ - enabled: recentsOpenHydrated && recentsOpen && hasMore && !loadingInitial && !loadMoreFailed, + enabled: recentsOpen && hasMore && !loadingInitial && !loadMoreFailed, targetRef: loadMoreRef, onLoadMore: loadMore, }) @@ -247,7 +220,7 @@ export function NavRecents() { /> - +
    -
    +
    diff --git a/frontend/features/layouts/components/navigation/nav-starred.tsx b/frontend/features/layouts/components/navigation/nav-starred.tsx index 74f5c5d8..41f17104 100644 --- a/frontend/features/layouts/components/navigation/nav-starred.tsx +++ b/frontend/features/layouts/components/navigation/nav-starred.tsx @@ -9,7 +9,6 @@ import { useTranslations } from "next-intl" import { List } from "@/components/animate-ui/icons/list" import { Collapsible, - CollapsibleContent, } from "@/components/ui/collapsible" import { AlertDialog, @@ -36,6 +35,7 @@ import { ConversationShareDialog, sharePatchFromDTO, } from "@/features/chat/components/sections/chat-share-dialog" +import { CollapsibleMotionContent } from "@/shared/components/collapsible-motion-content" import { useChatConversationExport } from "@/features/chat/hooks/use-chat-conversation-export" import { DeleteFilesOption } from "@/shared/components/delete-files-option" import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences" @@ -50,6 +50,7 @@ import type { import { filterConversationSearchResults } from "@/features/layouts/utils/navigation-search" import { useSidebarRecents } from "@/features/recent/context/sidebar-recents-context" import type { ConversationDTO } from "@/shared/api/conversation.types" +import { useStoredBoolean } from "@/shared/hooks/use-stored-boolean" import { cn } from "@/lib/utils" const STARRED_SKELETON_WIDTHS = ["71%", "59%", "66%", "54%", "70%"] as const @@ -98,8 +99,7 @@ export function NavStarred() { const [shareTarget, setShareTarget] = React.useState<{ publicID: string; title: string } | null>(null) const [renameValue, setRenameValue] = React.useState("") const [autoRenamingPublicID, setAutoRenamingPublicID] = React.useState(null) - const [starredOpen, setStarredOpen] = React.useState(true) - const [starredOpenHydrated, setStarredOpenHydrated] = React.useState(false) + const [starredOpen, setStarredOpen] = useStoredBoolean(STARRED_OPEN_STORAGE_KEY, true) const listContainerRef = React.useRef(null) const deleteFilesID = React.useId() const starredContentID = React.useId() @@ -133,33 +133,6 @@ export function NavStarred() { excludeKey: transferringStarPublicID, }) - React.useEffect(() => { - try { - const stored = window.localStorage.getItem(STARRED_OPEN_STORAGE_KEY) - if (stored === "true") { - setStarredOpen(true) - } else if (stored === "false") { - setStarredOpen(false) - } - } catch { - // Keep the default open state when localStorage is unavailable. - } finally { - setStarredOpenHydrated(true) - } - }, []) - - React.useEffect(() => { - if (!starredOpenHydrated) { - return - } - - try { - window.localStorage.setItem(STARRED_OPEN_STORAGE_KEY, starredOpen ? "true" : "false") - } catch { - // Ignore storage failures; the current in-memory state still controls the UI. - } - }, [starredOpen, starredOpenHydrated]) - React.useEffect(() => { if (!showAllStarredDialog) { setDialogLoading(false) @@ -318,7 +291,7 @@ export function NavStarred() { /> - +
    -
    + diff --git a/frontend/features/admin/components/sections/shared/settings-collapsible-content.tsx b/frontend/shared/components/collapsible-motion-content.tsx similarity index 62% rename from frontend/features/admin/components/sections/shared/settings-collapsible-content.tsx rename to frontend/shared/components/collapsible-motion-content.tsx index 72ed3abb..de3fbdc5 100644 --- a/frontend/features/admin/components/sections/shared/settings-collapsible-content.tsx +++ b/frontend/shared/components/collapsible-motion-content.tsx @@ -1,38 +1,40 @@ "use client"; import * as React from "react"; -import { AnimatePresence, motion } from "motion/react"; +import { AnimatePresence, motion, type HTMLMotionProps } from "motion/react"; import { cn } from "@/lib/utils"; -type SettingsCollapsibleContentProps = { +type CollapsibleMotionContentProps = Omit, "animate" | "children" | "exit" | "initial" | "transition"> & { open: boolean; children: React.ReactNode; - className?: string; contentClassName?: string; }; -const SETTINGS_COLLAPSE_TRANSITION = { +const COLLAPSIBLE_MOTION_TRANSITION = { duration: 0.2, ease: [0.22, 1, 0.36, 1], } as const; -export function SettingsCollapsibleContent({ +export function CollapsibleMotionContent({ open, children, className, contentClassName, -}: SettingsCollapsibleContentProps) { + style, + ...props +}: CollapsibleMotionContentProps) { return ( {open ? (
    {children} diff --git a/frontend/shared/hooks/use-stored-boolean.ts b/frontend/shared/hooks/use-stored-boolean.ts new file mode 100644 index 00000000..f6e368b4 --- /dev/null +++ b/frontend/shared/hooks/use-stored-boolean.ts @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" + +export function useStoredBoolean(storageKey: string, defaultValue: boolean) { + const [value, setValue] = React.useState(() => { + if (typeof window === "undefined") { + return defaultValue + } + + try { + const stored = window.localStorage.getItem(storageKey) + if (stored === "true") { + return true + } + if (stored === "false") { + return false + } + } catch { + return defaultValue + } + + return defaultValue + }) + + React.useEffect(() => { + try { + window.localStorage.setItem(storageKey, value ? "true" : "false") + } catch { + // localStorage can be unavailable in private browsing or strict environments. + } + }, [storageKey, value]) + + return [value, setValue] as const +} diff --git a/review(1).md b/review(1).md new file mode 100644 index 00000000..370dc760 --- /dev/null +++ b/review(1).md @@ -0,0 +1,83 @@ +# 代码审查报告 + +**分支:** `feat_collapse_recent` vs `origin/main` +**日期:** 2026-06-16 +**涉及文件:** 13 个(后端 4 个 · 前端 9 个) + +--- + +## 问题一 — [已确认 · 严重] 折叠后重新展开导致无限滚动永久失效 + +**文件:** `frontend/features/layouts/components/navigation/nav-recents.tsx` + +Radix `CollapsibleContent` 在未设置 `forceMount` 时,默认会**卸载**折叠区域的子节点。折叠时 `loadMoreRef.current` 变为 `null`;重新展开时 `enabled` 翻回 `true` 触发 `useLoadMoreSentinel` 的 effect,但此时 DOM 节点尚未挂载,effect 内部的 `if (!target) return` 提前退出,`IntersectionObserver` 永远注册不上。结果:**用户折叠再展开"最近对话"区块后,本次会话的无限滚动彻底失效**,直到刷新页面。 + +**修复建议:** 给 `CollapsibleContent` 传 `forceMount`(保留 DOM 节点)并通过 CSS 控制可见性;或在 `useLoadMoreSentinel` 中改用回调 ref(`ref={node => { loadMoreRef.current = node }}`),让 observer 在节点挂载时自动注册。 + +--- + +## 问题二 — [已确认 · 中等] 存储"折叠"偏好的用户每次刷新看到展开→折叠闪烁 + +**文件:** `frontend/features/layouts/components/navigation/nav-recents.tsx:84` · `nav-starred.tsx:101` + +两个组件用 `useEffect`(绘制后异步执行)读取 `localStorage`,而初始状态固定为 `true`(展开)。对于上次选择折叠的用户,每次刷新都会出现"先展开一帧再折叠"的视觉闪烁。对比 `sidebar.tsx` 的正确做法: + +```ts +// sidebar.tsx — 正确:在 useState 初始化时同步读取 +const [_open, _setOpen] = React.useState( + () => resolveSidebarPreferredOpen(defaultOpen) && !isCompactSidebarViewport() +) +``` + +**修复建议:** 将 `nav-recents.tsx` 和 `nav-starred.tsx` 的初始状态改为懒初始化方式同步读取 localStorage,与 `sidebar.tsx` 保持一致: + +```ts +const [recentsOpen, setRecentsOpen] = React.useState(() => { + try { + const s = window.localStorage.getItem(RECENTS_OPEN_STORAGE_KEY) + if (s === "false") return false + } catch {} + return true +}) +``` + +--- + +## 问题三 — [可能存在 · 中等] 删除 `!defaultOpen` 守卫引入潜在自动展开逻辑缺口 + +**文件:** `frontend/components/ui/sidebar.tsx:163` + +```diff +- if (!leftCompactViewport || !autoCollapsedRef.current || !defaultOpen) { ++ if (!leftCompactViewport || !autoCollapsedRef.current) { +``` + +若某调用方将来以 `defaultOpen={false}` 挂载侧边栏,但 localStorage 中仍存有上次 `"true"` 的记录,则 `resolveSidebarPreferredOpen(false)` 会返回 `true`,导致 `autoCollapsedRef.current` 初始化为 `true`。此时删掉的守卫正好会阻止自动展开,而现在已无法阻止。当前代码库没有传 `false` 的调用方,属于潜在风险,但值得记录。 + +--- + +## 问题四 — [可能存在 · 低] 头像异步保存期间组件卸载导致无效 setState + +**文件:** `frontend/features/settings/components/sections/general/settings-general.tsx:271` + +`handleSaveAvatarDialog` 改为 `async`,`await patchMe(...)` 期间若用户导航离开,`finally` 块中的 `setSaving / setViewer / setDraft` 等调用会作用于已卸载的组件。React 18 下这通常是无害的警告,但在并发模式下可能将过期闭包中捕获的 `accessToken`、`initialDraft.avatarUrl` 写回,影响后续挂载的新实例。建议添加 `AbortController` 或 `isMounted` 守卫。 + +--- + +## 清理建议 — `nav-recents` 与 `nav-starred` 水合逻辑完全重复 + +两个文件各有一对结构完全相同的 `useEffect`(读取 localStorage + 写回守卫),变量名不同但逻辑相同。建议提取为共享 hook,例如 `useLocalStorageBoolean(key, defaultValue)`,同时可顺带解决上述问题二。 + +--- + +## 总结 + +| # | 严重程度 | 状态 | 描述 | +|---|----------|------|------| +| 1 | 严重 | 已确认 | 折叠→展开后无限滚动永久失效 | +| 2 | 中等 | 已确认 | 存储折叠偏好的用户刷新时出现展开闪烁 | +| 3 | 中等 | 可能存在 | 删除 `!defaultOpen` 守卫的潜在自动展开缺口 | +| 4 | 低 | 可能存在 | 头像保存异步期间组件卸载的 setState 风险 | +| — | — | 清理 | nav-recents/nav-starred localStorage 水合逻辑重复 | + +最需要立即处理的是**问题一**和**问题二**,两者可通过同步初始化 + `CollapsibleContent` 加 `forceMount` 一并解决。 From 6b28df7cdd18b0cb572ff2ece8a898d9019e24b7 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:37:27 +0800 Subject: [PATCH 10/31] feat: integrate skill management into conversation service and UI --- backend/internal/app/app.go | 10 + .../internal/application/conversation/errs.go | 6 + .../application/conversation/prompt_plan.go | 30 ++ .../application/conversation/service.go | 12 + .../conversation/service_message_send.go | 24 + .../conversation/service_skill_prompt.go | 171 +++++++ .../conversation/service_skill_prompt_test.go | 105 +++++ .../application/promptpreset/service.go | 6 +- .../application/promptpreset/service_test.go | 7 +- backend/internal/application/skill/errs.go | 12 + backend/internal/application/skill/service.go | 379 +++++++++++++++ .../application/skill/service_test.go | 96 ++++ backend/internal/domain/skill/doc.go | 2 + backend/internal/domain/skill/types.go | 27 ++ .../infra/persistence/models/prompt_preset.go | 6 +- .../infra/persistence/models/skill.go | 21 + .../infra/persistence/postgres/postgres.go | 1 + .../persistence/postgres/skill/repository.go | 259 +++++++++++ .../infra/persistence/schema/schema.go | 16 +- backend/internal/repository/skill.go | 38 ++ .../internal/shared/response/error_code.go | 1 + .../http/conversation/dto_request.go | 1 + .../transport/http/conversation/handler.go | 9 + .../http/conversation/handler_message_send.go | 7 + .../transport/http/promptpreset/dto.go | 16 +- backend/internal/transport/http/server.go | 10 +- backend/internal/transport/http/skill/dto.go | 148 ++++++ .../internal/transport/http/skill/dto_test.go | 46 ++ .../internal/transport/http/skill/handler.go | 390 ++++++++++++++++ .../internal/transport/http/skill/module.go | 11 + .../internal/transport/http/skill/router.go | 21 + .../transport/http/skill/router_test.go | 72 +++ .../conversation-prompt-presets.tsx | 196 ++++++-- .../features/admin/hooks/use-admin-skills.ts | 189 ++++++++ .../chat/components/app-chat-area.tsx | 7 + .../message/message-process-trace.tsx | 5 + .../chat/components/message/message-user.tsx | 2 + .../chat/components/sections/chat-input.tsx | 80 +++- .../components/shared/chat-mention-menu.tsx | 18 +- .../chat/hooks/use-chat-mention-menu.ts | 125 ++++- .../chat/hooks/use-chat-message-submit.ts | 9 + .../features/chat/hooks/use-chat-runtime.ts | 7 + .../chat/hooks/use-chat-submit-stream.ts | 7 + .../layouts/model/navigation-items.ts | 2 +- .../sections/skills-prompt-page.tsx | 95 ++-- .../components/sections/skills-section.tsx | 432 ++++++++++++++++++ .../i18n/messages/en-US/admin-prompts.json | 26 +- frontend/i18n/messages/en-US/chat.json | 5 + frontend/i18n/messages/en-US/common.json | 2 +- frontend/i18n/messages/en-US/prompts.json | 29 +- .../i18n/messages/zh-CN/admin-prompts.json | 26 +- frontend/i18n/messages/zh-CN/chat.json | 7 +- frontend/i18n/messages/zh-CN/common.json | 2 +- frontend/i18n/messages/zh-CN/prompts.json | 29 +- frontend/shared/api/conversation.types.ts | 1 + frontend/shared/api/skills.ts | 126 +++++ frontend/shared/api/skills.types.ts | 48 ++ frontend/shared/model/prompt-presets.ts | 6 +- frontend/shared/model/skills.ts | 63 +++ 59 files changed, 3375 insertions(+), 129 deletions(-) create mode 100644 backend/internal/application/conversation/service_skill_prompt.go create mode 100644 backend/internal/application/conversation/service_skill_prompt_test.go create mode 100644 backend/internal/application/skill/errs.go create mode 100644 backend/internal/application/skill/service.go create mode 100644 backend/internal/application/skill/service_test.go create mode 100644 backend/internal/domain/skill/doc.go create mode 100644 backend/internal/domain/skill/types.go create mode 100644 backend/internal/infra/persistence/models/skill.go create mode 100644 backend/internal/infra/persistence/postgres/skill/repository.go create mode 100644 backend/internal/repository/skill.go create mode 100644 backend/internal/transport/http/skill/dto.go create mode 100644 backend/internal/transport/http/skill/dto_test.go create mode 100644 backend/internal/transport/http/skill/handler.go create mode 100644 backend/internal/transport/http/skill/module.go create mode 100644 backend/internal/transport/http/skill/router.go create mode 100644 backend/internal/transport/http/skill/router_test.go create mode 100644 frontend/features/admin/hooks/use-admin-skills.ts create mode 100644 frontend/features/prompts/components/sections/skills-section.tsx create mode 100644 frontend/shared/api/skills.ts create mode 100644 frontend/shared/api/skills.types.ts create mode 100644 frontend/shared/model/skills.ts diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index cde910c2..0dfbc0e9 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -27,6 +27,7 @@ import ( apprag "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/rag" appruntime "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/runtime" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/settings" + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" appsystemevent "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/systemevent" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/user" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/usersettings" @@ -46,6 +47,7 @@ import ( memoryrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/memory" promptpresetrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/promptpreset" settingsrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/settings" + skillrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/skill" systemeventrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/systemevent" userrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/user" usersettingsrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/usersettings" @@ -61,6 +63,7 @@ import ( memoryhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/memory" promptpresethttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/promptpreset" settingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/settings" + skillhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/skill" userhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/user" usersettingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/usersettings" "github.com/gin-gonic/gin" @@ -274,6 +277,12 @@ func NewApp() (*App, error) { promptPresetService.SetAuditWriter(auditService) promptPresetHandler := promptpresethttp.NewHandler(promptPresetService) promptPresetModule := promptpresethttp.NewModule(promptPresetHandler) + skillRepo := skillrepo.NewRepo(db) + skillService := appskill.NewService(skillRepo) + skillService.SetAuditWriter(auditService) + conversationService.SetSkillResolver(skillService) + skillHandler := skillhttp.NewHandler(skillService) + skillModule := skillhttp.NewModule(skillHandler) hc := newHealthChecker(db, cfg.CacheDriver, redisClient) rateLimiter := buildRateLimiter(cfg, redisClient, memoryCache) @@ -288,6 +297,7 @@ func NewApp() (*App, error) { Admin: adminModule, Announcement: announcementModule, PromptPreset: promptPresetModule, + Skill: skillModule, Settings: settingsModule, UserSettings: userSettingsModule, User: userModule, diff --git a/backend/internal/application/conversation/errs.go b/backend/internal/application/conversation/errs.go index 6f07c9c9..3c91f239 100644 --- a/backend/internal/application/conversation/errs.go +++ b/backend/internal/application/conversation/errs.go @@ -43,6 +43,12 @@ var ( ErrTooManyMessageFiles = errors.New("too many message files") // ErrTooManySelectedTools 单条消息选择的 MCP 工具数超限。 ErrTooManySelectedTools = errors.New("too many selected tools") + // ErrTooManySelectedSkills 单条消息选择的 Skill 数超限。 + ErrTooManySelectedSkills = errors.New("too many selected skills") + // ErrSkillNotFound 技能不存在或当前用户不可用。 + ErrSkillNotFound = errors.New("skill not found") + // ErrInvalidSkillUse 技能使用入参不合法。 + ErrInvalidSkillUse = errors.New("invalid skill use") // ErrInvalidMessageBranch 消息分支参数无效。 ErrInvalidMessageBranch = errors.New("invalid message branch") // ErrInvalidMessageContent 消息内容不合法。 diff --git a/backend/internal/application/conversation/prompt_plan.go b/backend/internal/application/conversation/prompt_plan.go index 5f0501c4..3313a902 100644 --- a/backend/internal/application/conversation/prompt_plan.go +++ b/backend/internal/application/conversation/prompt_plan.go @@ -7,6 +7,7 @@ import ( appstorage "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/objectstorage" domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) @@ -19,6 +20,7 @@ const ( PromptBlockStableContext PromptBlockKind = "stable_context" PromptBlockHistoricalEvidence PromptBlockKind = "historical_evidence" PromptBlockDynamicContext PromptBlockKind = "dynamic_context" + PromptBlockSkillContext PromptBlockKind = "skill_context" PromptBlockToolGuidance PromptBlockKind = "tool_guidance" PromptBlockTranscript PromptBlockKind = "transcript" ) @@ -57,6 +59,7 @@ type promptPlanInput struct { BaseMessages []llm.Message StableAttachments []AttachmentInput DynamicContext userContextInput + SkillPrompts *skillPrompts ToolRuntime selectedToolRuntime Config config.Config StoreProvider appstorage.Provider @@ -127,6 +130,24 @@ func buildPromptPlan(ctx context.Context, input promptPlanInput) PromptPlan { } } + before = len(messages) + messages = injectSkillPrompts(messages, input.SkillPrompts) + if len(messages) > before && input.SkillPrompts != nil { + inserted := findSkillPromptMessage(messages) + tokenEstimate := int64(0) + if inserted >= 0 { + tokenEstimate = estimateMessageTokens(messages[inserted]) + } + trace.addBlock(PromptBlockTrace{ + Kind: PromptBlockSkillContext, + Title: "Skill 上下文", + TokenEstimate: tokenEstimate, + Cacheable: true, + SourceCount: len(input.SkillPrompts.Skills), + SourceRefs: skillPromptSourceRefs(input.SkillPrompts.Skills), + }) + } + before = len(messages) messages = injectMCPToolGuidance(messages, input.ToolRuntime) if len(messages) > before { @@ -305,6 +326,15 @@ func historicalArtifactSourceRefs(artifacts []domainconversation.ContextArtifact return refs } +// skillPromptSourceRefs 提取本轮可用 Skill 的来源引用。 +func skillPromptSourceRefs(skills []domainskill.Skill) []PromptSourceRef { + refs := make([]PromptSourceRef, 0, len(skills)) + for _, skill := range skills { + refs = appendPromptSourceRef(refs, "skill", fmt.Sprintf("%d", skill.ID), skill.Title) + } + return refs +} + // toolDefinitionSourceRefs 提取本轮可用工具定义的来源引用。 func toolDefinitionSourceRefs(tools []llm.ToolDefinition) []PromptSourceRef { refs := make([]PromptSourceRef, 0, len(tools)) diff --git a/backend/internal/application/conversation/service.go b/backend/internal/application/conversation/service.go index 5086f4f6..a3319238 100644 --- a/backend/internal/application/conversation/service.go +++ b/backend/internal/application/conversation/service.go @@ -16,6 +16,7 @@ import ( appupload "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/upload" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" domainmemory "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/memory" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/embedding" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" @@ -48,6 +49,10 @@ type memoryRecorder interface { UpsertUserMemoryEmbedding(ctx context.Context, userID uint, memoryKey string, expectedValue string, embedding []float32) error } +type skillResolver interface { + ResolveAvailable(ctx context.Context, userID uint, id uint) (*domainskill.Skill, error) +} + type auditWriter interface { Write(ctx context.Context, requestID string, actorUserID uint, action string, resource string, resourceID string, ip string, userAgent string, detail interface{}) } @@ -75,6 +80,7 @@ type Service struct { processingSvc *appprocessing.Service extractSvc *extraction.Service ragSvc *apprag.Service + skillResolver skillResolver billingSvc *appbilling.Service auditWriter auditWriter storeProvider appstorage.Provider @@ -133,6 +139,7 @@ type SendMessageInput struct { ClientRunID string FileIDs []string SelectedToolIDs []uint + SkillIDs []uint HTMLVisualPromptEnabled bool HTMLVisualColorMode string ParentMessagePublicID string @@ -143,6 +150,11 @@ type SendMessageInput struct { OnEvent func(eventType string, payload map[string]interface{}) error } +// SetSkillResolver 注入会话技能解析器。 +func (s *Service) SetSkillResolver(resolver skillResolver) { + s.skillResolver = resolver +} + // SendMessageResult 返回用户消息与 AI 消息。 type SendMessageResult struct { UserMessage model.Message diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index babd7a25..22ae2785 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -635,11 +635,35 @@ func (s *Service) sendMessageInternal( RecallChunks: userCtx.RecallChunks, Memories: userCtx.Memory, }) + skillPrompts, err := s.resolveSkillPrompts(ctx, input) + if err != nil { + retErr = err + return nil, err + } + if traceRecorder != nil && skillPrompts != nil { + skillTitles := skillPromptTitles(skillPrompts.Skills) + traceRecorder.appendProcessSection( + fmt.Sprintf("已提供 %d 个 Skill 上下文", len(skillPrompts.Skills)), + formatTraceStep("Skill", fmt.Sprintf("本轮已加载 Skill:%s。包含 SKILL.md 内容,相关时使用。", strings.Join(skillTitles, "、"))), + map[string]interface{}{ + processTracePayloadStage: map[string]interface{}{ + "kind": "skill_context", + "status": messageTraceStatusStreaming, + }, + "skill_count": len(skillPrompts.Skills), + "skill_ids": skillPromptIDs(skillPrompts.Skills), + "skill_titles": skillTitles, + "skill_triggers": skillPromptTriggers(skillPrompts.Skills), + }, + messageTraceStatusStreaming, + ) + } toolRuntime := s.resolveSelectedToolRuntime(ctx, input.SelectedToolIDs) promptPlan := buildPromptPlan(ctx, promptPlanInput{ BaseMessages: llmMessages, StableAttachments: stableFullContextAttachments, DynamicContext: userCtx, + SkillPrompts: skillPrompts, ToolRuntime: toolRuntime, Config: cfg, StoreProvider: s.storeProvider, diff --git a/backend/internal/application/conversation/service_skill_prompt.go b/backend/internal/application/conversation/service_skill_prompt.go new file mode 100644 index 00000000..25ac01fe --- /dev/null +++ b/backend/internal/application/conversation/service_skill_prompt.go @@ -0,0 +1,171 @@ +package conversation + +import ( + "context" + "errors" + "fmt" + "strings" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +const skillPromptSystemMarker = "" + +type skillPrompts struct { + Skills []domainskill.Skill + Rendered string +} + +func (s *Service) resolveSkillPrompts(ctx context.Context, input SendMessageInput) (*skillPrompts, error) { + skillIDs := normalizeSelectedSkillIDs(input.SkillIDs) + if len(skillIDs) == 0 { + return nil, nil + } + if len(skillIDs) > s.resolveMaxSelectedSkillsPerMessage() { + return nil, ErrTooManySelectedSkills + } + if s.skillResolver == nil { + return nil, ErrSkillNotFound + } + skills := make([]domainskill.Skill, 0, len(skillIDs)) + for _, skillID := range skillIDs { + skill, err := s.skillResolver.ResolveAvailable(ctx, input.UserID, skillID) + if err != nil { + if errors.Is(err, appskill.ErrSkillNotFound) || errors.Is(err, repository.ErrNotFound) { + return nil, ErrSkillNotFound + } + if errors.Is(err, appskill.ErrInvalidSkill) || errors.Is(err, repository.ErrInvalidInput) { + return nil, ErrInvalidSkillUse + } + return nil, err + } + if skill != nil { + skills = append(skills, *skill) + } + } + prompt := &skillPrompts{Skills: skills} + prompt.Rendered = renderSkillPrompts(prompt) + return prompt, nil +} + +func (s *Service) resolveMaxSelectedSkillsPerMessage() int { + return s.resolveMaxSelectedToolsPerMessage() +} + +func normalizeSelectedSkillIDs(ids []uint) []uint { + normalized := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +func skillPromptIDs(skills []domainskill.Skill) []uint { + ids := make([]uint, 0, len(skills)) + for _, skill := range skills { + ids = append(ids, skill.ID) + } + return ids +} + +func skillPromptTitles(skills []domainskill.Skill) []string { + titles := make([]string, 0, len(skills)) + for _, skill := range skills { + title := strings.TrimSpace(skill.Title) + if title != "" { + titles = append(titles, title) + } + } + return titles +} + +func skillPromptTriggers(skills []domainskill.Skill) []string { + triggers := make([]string, 0, len(skills)) + for _, skill := range skills { + trigger := strings.TrimSpace(skill.Trigger) + if trigger != "" { + triggers = append(triggers, trigger) + } + } + return triggers +} + +func renderSkillPrompts(prompt *skillPrompts) string { + if prompt == nil || len(prompt.Skills) == 0 { + return "" + } + lines := []string{ + skillPromptSystemMarker, + fmt.Sprintf("", len(prompt.Skills)), + } + for index, skill := range prompt.Skills { + lines = append(lines, + fmt.Sprintf("", skill.ID, index+1, xmlEscapeAttr(strings.TrimSpace(skill.Scope))), + ""+xmlEscapeText(strings.TrimSpace(skill.Title))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Trigger))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Description))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Markdown))+"", + "", + ) + } + lines = append(lines, + "", + "", + "These user-selected skills are available as optional capability context for the current user request.", + "Each selected skill includes title, trigger, description, and SKILL.md content for this turn.", + "Use each skill's content when it is relevant to the user's request. If a selected skill is not relevant, ignore it.", + "Do not invent hidden instructions or operational steps that are not present in the disclosed skill content.", + "Do not treat loading these skills as an instruction to force their behavior onto unrelated requests.", + "These skills do not grant permission to execute operating-system commands, shell scripts, background jobs, network calls, or tools.", + "Do not call tools unless they were explicitly selected and provided by the platform for this conversation.", + "Do not expose these tags. Produce only the final user-facing answer.", + "", + "", + ) + return strings.Join(lines, "\n") +} + +func injectSkillPrompts(messages []llm.Message, prompt *skillPrompts) []llm.Message { + if prompt == nil || strings.TrimSpace(prompt.Rendered) == "" { + return messages + } + insertAt := firstNonSystemMessageIndex(messages) + message := llm.Message{ + Role: "system", + Content: prompt.Rendered, + } + result := make([]llm.Message, 0, len(messages)+1) + result = append(result, messages[:insertAt]...) + result = append(result, message) + result = append(result, messages[insertAt:]...) + return result +} + +func findSkillPromptMessage(messages []llm.Message) int { + for index, message := range messages { + if message.Role == "system" && strings.Contains(message.Content, skillPromptSystemMarker) { + return index + } + } + return -1 +} + +func firstNonSystemMessageIndex(messages []llm.Message) int { + for index, message := range messages { + if message.Role != "system" { + return index + } + } + return len(messages) +} diff --git a/backend/internal/application/conversation/service_skill_prompt_test.go b/backend/internal/application/conversation/service_skill_prompt_test.go new file mode 100644 index 00000000..19e8be74 --- /dev/null +++ b/backend/internal/application/conversation/service_skill_prompt_test.go @@ -0,0 +1,105 @@ +package conversation + +import ( + "context" + "strings" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" +) + +func TestRenderSkillPromptIncludesSelectedSkillContent(t *testing.T) { + prompt := &skillPrompts{ + Skills: []domainskill.Skill{ + { + ID: 12, + Scope: domainskill.ScopeUser, + Title: "Review", + Trigger: "review", + Description: "Review code", + Markdown: "Review the diff and return prioritized findings.", + }, + { + ID: 13, + Scope: domainskill.ScopeBuiltin, + Title: "Frontend Rules", + Trigger: "frontend", + Markdown: "Check layout, spacing, and interaction states.", + }, + }, + } + rendered := renderSkillPrompts(prompt) + for _, want := range []string{ + "", + "", + "Review", + "Frontend Rules", + "Review code", + "Review the diff and return prioritized findings.", + "Check layout, spacing, and interaction states.", + "Each selected skill includes title, trigger, description, and SKILL.md content", + "Use each skill's content when it is relevant", + "Do not invent hidden instructions", + "do not grant permission to execute operating-system commands", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("expected rendered prompt to contain %q:\n%s", want, rendered) + } + } + if strings.Contains(rendered, "Apply this skill") { + t.Fatalf("expected rendered prompt not to force skill application:\n%s", rendered) + } +} + +func TestInjectSkillPromptAddsSystemMessageAfterExistingPolicy(t *testing.T) { + prompt := &skillPrompts{ + Skills: []domainskill.Skill{{ + ID: 7, + Scope: domainskill.ScopeBuiltin, + Title: "Plan", + Trigger: "plan", + Markdown: "Create a concise plan.", + }}, + } + prompt.Rendered = renderSkillPrompts(prompt) + + messages := injectSkillPrompts([]llm.Message{ + {Role: "system", Content: "base policy"}, + {Role: "user", Content: "hello"}, + }, prompt) + + if len(messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(messages)) + } + if messages[0].Content != "base policy" || messages[1].Role != "system" || !strings.Contains(messages[1].Content, skillPromptSystemMarker) { + t.Fatalf("expected skill prompt after base system policy: %#v", messages) + } + if messages[2].Role != "user" || messages[2].Content != "hello" { + t.Fatalf("expected original user message last: %#v", messages) + } +} + +func TestResolveSkillPromptsRejectsTooManySelectedSkills(t *testing.T) { + service := &Service{cfg: config.NewRuntime(config.Config{MCPMaxSelectedToolsPerMessage: 2})} + _, err := service.resolveSkillPrompts(context.Background(), SendMessageInput{ + SkillIDs: []uint{1, 2, 3}, + }) + if err != ErrTooManySelectedSkills { + t.Fatalf("expected ErrTooManySelectedSkills, got %v", err) + } +} + +func TestNormalizeSelectedSkillIDsDeduplicatesAndDropsEmpty(t *testing.T) { + got := normalizeSelectedSkillIDs([]uint{0, 2, 2, 3, 0, 1}) + want := []uint{2, 3, 1} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} diff --git a/backend/internal/application/promptpreset/service.go b/backend/internal/application/promptpreset/service.go index 53113307..3d7f7c97 100644 --- a/backend/internal/application/promptpreset/service.go +++ b/backend/internal/application/promptpreset/service.go @@ -10,9 +10,9 @@ import ( ) const ( - maxPromptPresetNameLength = 16 - maxPromptPresetDescriptionLength = 64 - maxPromptPresetContentLength = 16384 + maxPromptPresetNameLength = 64 + maxPromptPresetDescriptionLength = 256 + maxPromptPresetContentLength = 10000 ) // Service 封装预制提示词业务逻辑。 diff --git a/backend/internal/application/promptpreset/service_test.go b/backend/internal/application/promptpreset/service_test.go index a8a14fb1..fae30f53 100644 --- a/backend/internal/application/promptpreset/service_test.go +++ b/backend/internal/application/promptpreset/service_test.go @@ -3,6 +3,7 @@ package promptpreset import ( "context" "errors" + "strings" "testing" domainpromptpreset "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/promptpreset" @@ -47,7 +48,7 @@ func TestCreateUserAllowsChineseTrigger(t *testing.T) { t.Parallel() service := NewService(&fakePromptPresetRepo{items: map[uint]domainpromptpreset.PromptPreset{}}) - trigger := "一二三四五六七八九十一二三四五六" + trigger := strings.Repeat("中", maxPromptPresetNameLength) item, err := service.CreateUser(context.Background(), 1, WriteInput{ Title: trigger, Trigger: "/" + trigger, @@ -62,8 +63,8 @@ func TestCreateUserAllowsChineseTrigger(t *testing.T) { } _, err = service.CreateUser(context.Background(), 1, WriteInput{ - Title: trigger + "七", - Trigger: trigger + "七", + Title: trigger + "文", + Trigger: trigger + "文", Content: "帮助优化中文表达。", Enabled: true, }) diff --git a/backend/internal/application/skill/errs.go b/backend/internal/application/skill/errs.go new file mode 100644 index 00000000..3d2f7b19 --- /dev/null +++ b/backend/internal/application/skill/errs.go @@ -0,0 +1,12 @@ +package skill + +import "errors" + +var ( + // ErrSkillNotFound 表示技能不存在或当前用户无权访问。 + ErrSkillNotFound = errors.New("skill not found") + // ErrInvalidSkill 表示技能参数不合法。 + ErrInvalidSkill = errors.New("invalid skill") + // ErrSkillConflict 表示触发词在当前作用域内已存在。 + ErrSkillConflict = errors.New("skill trigger already exists") +) diff --git a/backend/internal/application/skill/service.go b/backend/internal/application/skill/service.go new file mode 100644 index 00000000..97763615 --- /dev/null +++ b/backend/internal/application/skill/service.go @@ -0,0 +1,379 @@ +package skill + +import ( + "context" + "errors" + "strings" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +const ( + maxSkillTitleLength = 64 + maxSkillTriggerLength = 64 + maxSkillDescriptionLength = 256 + maxSkillMarkdownLength = 10000 +) + +// Service 封装技能业务逻辑。 +type Service struct { + repo repository.SkillRepository + auditWriter auditWriter +} + +type auditWriter interface { + Write(ctx context.Context, requestID string, actorUserID uint, action string, resource string, resourceID string, ip string, userAgent string, detail interface{}) +} + +// NewService 创建技能服务。 +func NewService(repo repository.SkillRepository) *Service { + return &Service{repo: repo} +} + +// SetAuditWriter 注入审计写入器。 +func (s *Service) SetAuditWriter(writer auditWriter) { + s.auditWriter = writer +} + +// AuditInput 描述技能审计写入。 +type AuditInput struct { + UserID uint + RequestID string + Action string + ResourceID string + ClientIP string + UserAgent string + Detail interface{} +} + +// RecordAudit 记录技能审计日志。 +func (s *Service) RecordAudit(ctx context.Context, input AuditInput) { + if s.auditWriter == nil { + return + } + s.auditWriter.Write( + ctx, + strings.TrimSpace(input.RequestID), + input.UserID, + strings.TrimSpace(input.Action), + "skills", + strings.TrimSpace(input.ResourceID), + strings.TrimSpace(input.ClientIP), + strings.TrimSpace(input.UserAgent), + input.Detail, + ) +} + +// ListVisible 查询当前用户可使用的技能。 +func (s *Service) ListVisible(ctx context.Context, userID uint, input ListInput) ([]domainskill.Skill, int64, error) { + if userID == 0 { + return nil, 0, repository.ErrInvalidInput + } + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + VisibleUserID: &userID, + }, (page-1)*pageSize, pageSize) +} + +// ListMine 查询当前用户自定义技能。 +func (s *Service) ListMine(ctx context.Context, userID uint, input ListInput) ([]domainskill.Skill, int64, error) { + if userID == 0 { + return nil, 0, repository.ErrInvalidInput + } + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + SearchMarkdown: true, + Scope: domainskill.ScopeUser, + OwnerUserID: &userID, + Enabled: input.Enabled, + }, (page-1)*pageSize, pageSize) +} + +// ListAdminBuiltin 查询管理员内置技能列表。 +func (s *Service) ListAdminBuiltin(ctx context.Context, input ListInput) ([]domainskill.Skill, int64, error) { + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + SearchMarkdown: true, + Scope: domainskill.ScopeBuiltin, + Enabled: input.Enabled, + }, (page-1)*pageSize, pageSize) +} + +// ResolveAvailable 查询当前用户可使用的技能。 +func (s *Service) ResolveAvailable(ctx context.Context, userID uint, id uint) (*domainskill.Skill, error) { + if userID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if !item.Enabled { + return nil, ErrSkillNotFound + } + if item.Scope == domainskill.ScopeBuiltin { + return item, nil + } + if item.Scope == domainskill.ScopeUser && item.OwnerUserID == userID { + return item, nil + } + return nil, ErrSkillNotFound +} + +// CreateUser 创建用户自定义技能。 +func (s *Service) CreateUser(ctx context.Context, userID uint, input WriteInput) (*domainskill.Skill, error) { + if userID == 0 { + return nil, repository.ErrInvalidInput + } + item, err := normalizeWriteInput(input, domainskill.ScopeUser, userID, userID) + if err != nil { + return nil, err + } + return s.create(ctx, item) +} + +// CreateBuiltin 创建管理员内置技能。 +func (s *Service) CreateBuiltin(ctx context.Context, actorUserID uint, input WriteInput) (*domainskill.Skill, error) { + if actorUserID == 0 { + return nil, repository.ErrInvalidInput + } + item, err := normalizeWriteInput(input, domainskill.ScopeBuiltin, 0, actorUserID) + if err != nil { + return nil, err + } + return s.create(ctx, item) +} + +// UpdateUser 更新当前用户自定义技能。 +func (s *Service) UpdateUser(ctx context.Context, userID uint, id uint, input PatchInput) (*domainskill.Skill, error) { + if userID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeUser || item.OwnerUserID != userID { + return nil, ErrSkillNotFound + } + return s.update(ctx, id, userID, input) +} + +// UpdateBuiltin 更新管理员内置技能。 +func (s *Service) UpdateBuiltin(ctx context.Context, actorUserID uint, id uint, input PatchInput) (*domainskill.Skill, error) { + if actorUserID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeBuiltin { + return nil, ErrSkillNotFound + } + return s.update(ctx, id, actorUserID, input) +} + +// DeleteUser 删除当前用户自定义技能。 +func (s *Service) DeleteUser(ctx context.Context, userID uint, id uint) error { + if userID == 0 || id == 0 { + return repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeUser || item.OwnerUserID != userID { + return ErrSkillNotFound + } + return mapRepositoryError(s.repo.DeleteSkill(ctx, id)) +} + +// DeleteBuiltin 删除管理员内置技能。 +func (s *Service) DeleteBuiltin(ctx context.Context, actorUserID uint, id uint) error { + if actorUserID == 0 || id == 0 { + return repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeBuiltin { + return ErrSkillNotFound + } + return mapRepositoryError(s.repo.DeleteSkill(ctx, id)) +} + +// ListInput 定义技能列表入参。 +type ListInput struct { + Query string + Enabled *bool + Page int + PageSize int +} + +// WriteInput 定义技能创建入参。 +type WriteInput struct { + Title string + Trigger string + Description string + Markdown string + Enabled bool + SortOrder int +} + +// PatchInput 定义技能更新入参。 +type PatchInput struct { + Title *string + Trigger *string + Description *string + Markdown *string + Enabled *bool + SortOrder *int +} + +func (s *Service) create(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + result, err := s.repo.CreateSkill(ctx, item) + if err != nil { + return nil, mapRepositoryError(err) + } + return result, nil +} + +func (s *Service) update(ctx context.Context, id uint, actorUserID uint, input PatchInput) (*domainskill.Skill, error) { + patch, err := normalizePatchInput(input, actorUserID) + if err != nil { + return nil, err + } + item, err := s.repo.PatchSkill(ctx, id, patch) + if err != nil { + return nil, mapRepositoryError(err) + } + return item, nil +} + +func normalizeWriteInput(input WriteInput, scope string, ownerUserID uint, actorUserID uint) (*domainskill.Skill, error) { + title, trigger, description, markdown, err := normalizeFields(input) + if err != nil { + return nil, err + } + if scope != domainskill.ScopeBuiltin && scope != domainskill.ScopeUser { + return nil, ErrInvalidSkill + } + return &domainskill.Skill{ + Scope: scope, + OwnerUserID: ownerUserID, + Title: title, + Trigger: trigger, + Description: description, + Markdown: markdown, + Enabled: input.Enabled, + SortOrder: input.SortOrder, + CreatedByUserID: actorUserID, + UpdatedByUserID: actorUserID, + }, nil +} + +func normalizePatchInput(input PatchInput, actorUserID uint) (repository.SkillPatch, error) { + patch := repository.SkillPatch{ + UpdatedByUserIDSet: true, + UpdatedByUserID: actorUserID, + } + if input.Title != nil { + title := strings.TrimSpace(*input.Title) + if title == "" || runeCount(title) > maxSkillTitleLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Title = &title + } + if input.Trigger != nil { + trigger := normalizeTrigger(*input.Trigger) + if trigger == "" || runeCount(trigger) > maxSkillTriggerLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Trigger = &trigger + } + if input.Description != nil { + description := strings.TrimSpace(*input.Description) + if runeCount(description) > maxSkillDescriptionLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Description = &description + } + if input.Markdown != nil { + markdown := strings.TrimSpace(*input.Markdown) + if markdown == "" || runeCount(markdown) > maxSkillMarkdownLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Markdown = &markdown + } + if input.Enabled != nil { + patch.Enabled = input.Enabled + } + if input.SortOrder != nil { + patch.SortOrder = input.SortOrder + } + return patch, nil +} + +func normalizeFields(input WriteInput) (string, string, string, string, error) { + title := strings.TrimSpace(input.Title) + trigger := normalizeTrigger(input.Trigger) + description := strings.TrimSpace(input.Description) + markdown := strings.TrimSpace(input.Markdown) + if title == "" || runeCount(title) > maxSkillTitleLength { + return "", "", "", "", ErrInvalidSkill + } + if trigger == "" || runeCount(trigger) > maxSkillTriggerLength { + return "", "", "", "", ErrInvalidSkill + } + if runeCount(description) > maxSkillDescriptionLength { + return "", "", "", "", ErrInvalidSkill + } + if markdown == "" || runeCount(markdown) > maxSkillMarkdownLength { + return "", "", "", "", ErrInvalidSkill + } + return title, trigger, description, markdown, nil +} + +func normalizeTrigger(value string) string { + return strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(value), "/")) +} + +func runeCount(value string) int { + return len([]rune(value)) +} + +func normalizePage(page int, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + const maxPageSize = 100 + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func mapRepositoryError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, repository.ErrNotFound) { + return ErrSkillNotFound + } + if errors.Is(err, repository.ErrDuplicate) { + return ErrSkillConflict + } + if errors.Is(err, repository.ErrInvalidInput) { + return ErrInvalidSkill + } + return err +} diff --git a/backend/internal/application/skill/service_test.go b/backend/internal/application/skill/service_test.go new file mode 100644 index 00000000..acb906b2 --- /dev/null +++ b/backend/internal/application/skill/service_test.go @@ -0,0 +1,96 @@ +package skill + +import ( + "context" + "errors" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +type fakeSkillRepo struct { + items map[uint]domainskill.Skill + next uint +} + +func (r *fakeSkillRepo) ListSkills(context.Context, repository.SkillListFilter, int, int) ([]domainskill.Skill, int64, error) { + return nil, 0, nil +} + +func (r *fakeSkillRepo) GetSkill(_ context.Context, id uint) (*domainskill.Skill, error) { + item, ok := r.items[id] + if !ok { + return nil, repository.ErrNotFound + } + return &item, nil +} + +func (r *fakeSkillRepo) CreateSkill(_ context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + if r.next == 0 { + r.next = 1 + } + item.ID = r.next + r.next++ + r.items[item.ID] = *item + result := r.items[item.ID] + return &result, nil +} + +func (r *fakeSkillRepo) PatchSkill(context.Context, uint, repository.SkillPatch) (*domainskill.Skill, error) { + return nil, nil +} + +func (r *fakeSkillRepo) DeleteSkill(context.Context, uint) error { + return nil +} + +func TestCreateUserRequiresMarkdown(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{}}) + _, err := service.CreateUser(context.Background(), 7, WriteInput{ + Title: "Review", + Trigger: "review", + Enabled: true, + }) + if !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("expected ErrInvalidSkill, got %v", err) + } +} + +func TestCreateUserStoresMarkdown(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{}}) + item, err := service.CreateUser(context.Background(), 7, WriteInput{ + Title: "Review", + Trigger: "/review", + Description: "Review code", + Markdown: "Review the submitted code and return prioritized findings.", + Enabled: true, + }) + if err != nil { + t.Fatalf("expected skill to be created, got %v", err) + } + if item.Trigger != "review" { + t.Fatalf("expected normalized trigger, got %q", item.Trigger) + } + if item.Markdown != "Review the submitted code and return prioritized findings." { + t.Fatalf("unexpected markdown: %q", item.Markdown) + } +} + +func TestResolveAvailableEnforcesVisibility(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{ + 1: {ID: 1, Scope: domainskill.ScopeUser, OwnerUserID: 8, Enabled: true, Title: "Private"}, + 2: {ID: 2, Scope: domainskill.ScopeBuiltin, Enabled: true, Title: "Builtin"}, + }}) + + if _, err := service.ResolveAvailable(context.Background(), 7, 1); !errors.Is(err, ErrSkillNotFound) { + t.Fatalf("expected ErrSkillNotFound, got %v", err) + } + item, err := service.ResolveAvailable(context.Background(), 7, 2) + if err != nil { + t.Fatalf("expected builtin visible, got %v", err) + } + if item.ID != 2 { + t.Fatalf("expected id 2, got %d", item.ID) + } +} diff --git a/backend/internal/domain/skill/doc.go b/backend/internal/domain/skill/doc.go new file mode 100644 index 00000000..74c459da --- /dev/null +++ b/backend/internal/domain/skill/doc.go @@ -0,0 +1,2 @@ +// Package skill 定义按需加载的技能提示词领域对象与常量。 +package skill diff --git a/backend/internal/domain/skill/types.go b/backend/internal/domain/skill/types.go new file mode 100644 index 00000000..221a9981 --- /dev/null +++ b/backend/internal/domain/skill/types.go @@ -0,0 +1,27 @@ +package skill + +import "time" + +const ( + // ScopeBuiltin 表示管理员维护的全局内置技能。 + ScopeBuiltin = "builtin" + // ScopeUser 表示用户维护的个人自定义技能。 + ScopeUser = "user" +) + +// Skill 表示可在会话中按需加载的 SKILL.md 能力包。 +type Skill struct { + ID uint + Scope string + OwnerUserID uint + Title string + Trigger string + Description string + Markdown string + Enabled bool + SortOrder int + CreatedByUserID uint + UpdatedByUserID uint + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/infra/persistence/models/prompt_preset.go b/backend/internal/infra/persistence/models/prompt_preset.go index e8a7dea3..53f48f42 100644 --- a/backend/internal/infra/persistence/models/prompt_preset.go +++ b/backend/internal/infra/persistence/models/prompt_preset.go @@ -5,9 +5,9 @@ type PromptPreset struct { ControlPlaneModel Scope string `gorm:"size:32;not null;default:'user';index:idx_prompt_presets_scope;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:作用域(builtin/user)"` OwnerUserID uint `gorm:"not null;default:0;index:idx_prompt_presets_owner;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:所属用户ID,内置提示词为0"` - Title string `gorm:"size:16;not null;default:'';comment:提示词标题"` - Trigger string `gorm:"size:16;not null;default:'';index:idx_prompt_presets_trigger;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:slash触发词,不含斜杠"` - Description string `gorm:"size:64;not null;default:'';comment:提示词说明"` + Title string `gorm:"size:64;not null;default:'';comment:提示词标题"` + Trigger string `gorm:"size:64;not null;default:'';index:idx_prompt_presets_trigger;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:slash触发词,不含斜杠"` + Description string `gorm:"size:256;not null;default:'';comment:提示词说明"` Content string `gorm:"type:text;not null;default:'';comment:提示词内容"` Enabled bool `gorm:"not null;default:true;index:idx_prompt_presets_enabled;comment:是否启用"` SortOrder int `gorm:"not null;default:0;index:idx_prompt_presets_sort_order;comment:排序值"` diff --git a/backend/internal/infra/persistence/models/skill.go b/backend/internal/infra/persistence/models/skill.go new file mode 100644 index 00000000..289511f4 --- /dev/null +++ b/backend/internal/infra/persistence/models/skill.go @@ -0,0 +1,21 @@ +package model + +// Skill 记录平台内置和用户自定义的 SKILL.md 能力包。 +type Skill struct { + ControlPlaneModel + Scope string `gorm:"size:32;not null;default:'user';index:idx_skills_scope;uniqueIndex:idx_skills_scope_owner_trigger;comment:作用域(builtin/user)"` + OwnerUserID uint `gorm:"not null;default:0;index:idx_skills_owner;uniqueIndex:idx_skills_scope_owner_trigger;comment:所属用户ID,内置技能为0"` + Title string `gorm:"size:64;not null;default:'';comment:技能标题"` + Trigger string `gorm:"size:64;not null;default:'';index:idx_skills_trigger;uniqueIndex:idx_skills_scope_owner_trigger;comment:slash触发词,不含斜杠"` + Description string `gorm:"size:256;not null;default:'';comment:技能说明"` + Markdown string `gorm:"type:text;not null;default:'';comment:SKILL.md内容"` + Enabled bool `gorm:"not null;default:true;index:idx_skills_enabled;comment:是否启用"` + SortOrder int `gorm:"not null;default:0;index:idx_skills_sort_order;comment:排序值"` + CreatedByUserID uint `gorm:"not null;default:0;index:idx_skills_created_by;comment:创建人ID"` + UpdatedByUserID uint `gorm:"not null;default:0;index:idx_skills_updated_by;comment:最后更新人ID"` +} + +// TableName 指定表名。 +func (Skill) TableName() string { + return "skills" +} diff --git a/backend/internal/infra/persistence/postgres/postgres.go b/backend/internal/infra/persistence/postgres/postgres.go index 252c51bb..c1a6d8ef 100644 --- a/backend/internal/infra/persistence/postgres/postgres.go +++ b/backend/internal/infra/persistence/postgres/postgres.go @@ -144,6 +144,7 @@ func migrate(db *gorm.DB, cfg config.Config) error { "system_announcements": "站点公告表", "announcement_user_states": "用户公告展示状态表", "prompt_presets": "内置与用户自定义预制提示词表", + "skills": "内置与用户自定义技能提示词表", "system_settings": "系统动态配置表", "user_settings": "用户个人偏好配置表", "file_chunks": "RAG文件分片表", diff --git a/backend/internal/infra/persistence/postgres/skill/repository.go b/backend/internal/infra/persistence/postgres/skill/repository.go new file mode 100644 index 00000000..21cc6381 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/skill/repository.go @@ -0,0 +1,259 @@ +package skill + +import ( + "context" + "strings" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/dberror" + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Repo 封装技能数据访问。 +type Repo struct { + db *gorm.DB +} + +// NewRepo 创建技能仓储。 +func NewRepo(db *gorm.DB) *Repo { + return &Repo{db: db} +} + +// ListSkills 分页查询技能。 +func (r *Repo) ListSkills(ctx context.Context, filter repository.SkillListFilter, offset int, limit int) ([]domainskill.Skill, int64, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + items := make([]model.Skill, 0, limit) + var total int64 + query := r.db.WithContext(ctx).Model(&model.Skill{}) + query = applySkillFilter(query, filter) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, translateError(err) + } + if err := query. + Order(skillOrderClause(filter)). + Offset(offset). + Limit(limit). + Find(&items).Error; err != nil { + return nil, 0, translateError(err) + } + + results := make([]domainskill.Skill, 0, len(items)) + for _, item := range items { + results = append(results, toDomain(item)) + } + return results, total, nil +} + +// GetSkill 按主键查询技能。 +func (r *Repo) GetSkill(ctx context.Context, id uint) (*domainskill.Skill, error) { + if id == 0 { + return nil, repository.ErrInvalidInput + } + var record model.Skill + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&record).Error; err != nil { + return nil, translateError(err) + } + result := toDomain(record) + return &result, nil +} + +// CreateSkill 创建技能。 +func (r *Repo) CreateSkill(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + if item == nil { + return nil, repository.ErrInvalidInput + } + record := model.Skill{ + Scope: strings.TrimSpace(item.Scope), + OwnerUserID: item.OwnerUserID, + Title: strings.TrimSpace(item.Title), + Trigger: strings.TrimSpace(item.Trigger), + Description: strings.TrimSpace(item.Description), + Markdown: strings.TrimSpace(item.Markdown), + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + } + var result domainskill.Skill + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if record.SortOrder <= 0 { + var maxSortOrder int + if err := tx.Model(&model.Skill{}). + Where("scope = ? AND owner_user_id = ?", record.Scope, record.OwnerUserID). + Select("COALESCE(MAX(sort_order), 0)"). + Scan(&maxSortOrder).Error; err != nil { + return translateError(err) + } + record.SortOrder = maxSortOrder + 1 + } + if err := tx.Create(&record).Error; err != nil { + return translateError(err) + } + result = toDomain(record) + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// PatchSkill 更新技能字段。 +func (r *Repo) PatchSkill(ctx context.Context, id uint, patch repository.SkillPatch) (*domainskill.Skill, error) { + if id == 0 { + return nil, repository.ErrInvalidInput + } + var result domainskill.Skill + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record model.Skill + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + First(&record).Error; err != nil { + return translateError(err) + } + + updates := map[string]interface{}{} + if patch.Title != nil { + updates["title"] = strings.TrimSpace(*patch.Title) + } + if patch.Trigger != nil { + updates["trigger"] = strings.TrimSpace(*patch.Trigger) + } + if patch.Description != nil { + updates["description"] = strings.TrimSpace(*patch.Description) + } + if patch.Markdown != nil { + updates["markdown"] = strings.TrimSpace(*patch.Markdown) + } + if patch.Enabled != nil { + updates["enabled"] = *patch.Enabled + } + if patch.SortOrder != nil { + updates["sort_order"] = *patch.SortOrder + } + if patch.UpdatedByUserIDSet { + updates["updated_by_user_id"] = patch.UpdatedByUserID + } + if len(updates) > 0 { + if err := tx.Model(&record).Updates(updates).Error; err != nil { + return translateError(err) + } + } + if err := tx.Where("id = ?", id).First(&record).Error; err != nil { + return translateError(err) + } + result = toDomain(record) + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// DeleteSkill 删除技能。 +func (r *Repo) DeleteSkill(ctx context.Context, id uint) error { + if id == 0 { + return repository.ErrInvalidInput + } + result := r.db.WithContext(ctx).Delete(&model.Skill{}, id) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func applySkillFilter(query *gorm.DB, filter repository.SkillListFilter) *gorm.DB { + if filter.VisibleUserID != nil { + userID := *filter.VisibleUserID + query = query.Where( + "(scope = ? AND enabled = ?) OR (scope = ? AND owner_user_id = ? AND enabled = ?)", + domainskill.ScopeBuiltin, + true, + domainskill.ScopeUser, + userID, + true, + ) + } else { + if scope := strings.TrimSpace(filter.Scope); scope != "" { + query = query.Where("scope = ?", scope) + } + if filter.OwnerUserID != nil { + query = query.Where("owner_user_id = ?", *filter.OwnerUserID) + } + if filter.Enabled != nil { + query = query.Where("enabled = ?", *filter.Enabled) + } + } + if keyword := strings.TrimSpace(filter.Query); keyword != "" { + like := "%" + strings.ToLower(keyword) + "%" + if filter.SearchMarkdown { + query = query.Where( + "LOWER(title) LIKE ? OR LOWER(trigger) LIKE ? OR LOWER(description) LIKE ? OR LOWER(markdown) LIKE ?", + like, + like, + like, + like, + ) + } else { + query = query.Where( + "LOWER(title) LIKE ? OR LOWER(trigger) LIKE ? OR LOWER(description) LIKE ?", + like, + like, + like, + ) + } + } + return query +} + +func skillOrderClause(filter repository.SkillListFilter) string { + if filter.VisibleUserID != nil { + return "CASE WHEN scope = 'user' THEN 0 ELSE 1 END ASC, sort_order ASC, updated_at DESC, id DESC" + } + return "CASE WHEN enabled THEN 0 ELSE 1 END ASC, sort_order ASC, updated_at DESC, id DESC" +} + +func toDomain(item model.Skill) domainskill.Skill { + return domainskill.Skill{ + ID: item.ID, + Scope: item.Scope, + OwnerUserID: item.OwnerUserID, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Markdown: item.Markdown, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func translateError(err error) error { + if err == nil { + return nil + } + if dberror.IsRecordNotFound(err) { + return repository.ErrNotFound + } + if dberror.IsUniqueConstraint(err) { + return repository.ErrDuplicate + } + return err +} diff --git a/backend/internal/infra/persistence/schema/schema.go b/backend/internal/infra/persistence/schema/schema.go index 7b455220..f626f570 100644 --- a/backend/internal/infra/persistence/schema/schema.go +++ b/backend/internal/infra/persistence/schema/schema.go @@ -50,6 +50,7 @@ func Models() []interface{} { &model.Announcement{}, &model.AnnouncementUserState{}, &model.PromptPreset{}, + &model.Skill{}, &model.SystemSetting{}, &model.UserSetting{}, &model.FileChunk{}, @@ -84,14 +85,21 @@ func backfillUsageLedgerBillingAt(db *gorm.DB) error { // CleanupRemovedColumns drops columns that were removed from the Gorm models. func CleanupRemovedColumns(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.PromptPreset{}) { + if err := dropColumns(db, &model.PromptPreset{}, []string{"use_count", "last_used_at", "category", "tags_json"}); err != nil { + return err + } + return dropColumns(db, &model.Skill{}, []string{"content", "sections_json"}) +} + +func dropColumns(db *gorm.DB, table interface{}, columns []string) error { + if !db.Migrator().HasTable(table) { return nil } - for _, column := range []string{"use_count", "last_used_at", "category", "tags_json"} { - if !db.Migrator().HasColumn(&model.PromptPreset{}, column) { + for _, column := range columns { + if !db.Migrator().HasColumn(table, column) { continue } - if err := db.Migrator().DropColumn(&model.PromptPreset{}, column); err != nil { + if err := db.Migrator().DropColumn(table, column); err != nil { return err } } diff --git a/backend/internal/repository/skill.go b/backend/internal/repository/skill.go new file mode 100644 index 00000000..ed8a4d3c --- /dev/null +++ b/backend/internal/repository/skill.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +// SkillRepository 定义技能持久化能力。 +type SkillRepository interface { + ListSkills(ctx context.Context, filter SkillListFilter, offset int, limit int) ([]domainskill.Skill, int64, error) + GetSkill(ctx context.Context, id uint) (*domainskill.Skill, error) + CreateSkill(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) + PatchSkill(ctx context.Context, id uint, patch SkillPatch) (*domainskill.Skill, error) + DeleteSkill(ctx context.Context, id uint) error +} + +// SkillListFilter 描述技能列表筛选条件。 +type SkillListFilter struct { + Query string + SearchMarkdown bool + Scope string + OwnerUserID *uint + Enabled *bool + VisibleUserID *uint +} + +// SkillPatch 描述可更新的技能字段。 +type SkillPatch struct { + Title *string + Trigger *string + Description *string + Markdown *string + Enabled *bool + SortOrder *int + UpdatedByUserIDSet bool + UpdatedByUserID uint +} diff --git a/backend/internal/shared/response/error_code.go b/backend/internal/shared/response/error_code.go index e70c7718..57c52e06 100644 --- a/backend/internal/shared/response/error_code.go +++ b/backend/internal/shared/response/error_code.go @@ -154,6 +154,7 @@ var exactErrorSpecs = map[string]errorSpec{ "message generation canceled": {Code: "conversation_run.canceled", Message: "message generation canceled"}, "too many files in one message": {Code: "message.too_many_files", Message: "too many files in one message"}, "too many selected tools": {Code: "message.too_many_selected_tools", Message: "too many selected tools"}, + "too many selected skills": {Code: "message.too_many_selected_skills", Message: "too many selected skills"}, "generation stream not found": {Code: "conversation_run.stream_not_found", Message: "generation stream not found"}, "image prompt is required": {Code: "media.image_prompt_required", Message: "image prompt is required"}, "image generation does not accept input images": {Code: "media.image_generation_rejects_inputs", Message: "image generation does not accept input images"}, diff --git a/backend/internal/transport/http/conversation/dto_request.go b/backend/internal/transport/http/conversation/dto_request.go index 9e625aac..2fcc77d7 100644 --- a/backend/internal/transport/http/conversation/dto_request.go +++ b/backend/internal/transport/http/conversation/dto_request.go @@ -87,6 +87,7 @@ type SendMessageRequest struct { ClientRunID string `json:"clientRunID" binding:"omitempty,max=64"` FileIDs []string `json:"fileIDs" binding:"max=20"` SelectedToolIDs []uint `json:"selectedToolIDs" binding:"max=128"` + SkillIDs []uint `json:"skillIDs" binding:"max=128"` HTMLVisualPromptEnabled bool `json:"htmlVisualPrompt"` HTMLVisualColorMode string `json:"htmlVisualColorMode" binding:"omitempty,oneof=light dark"` ParentMessagePublicID string `json:"parentMessagePublicID" binding:"omitempty,max=32"` diff --git a/backend/internal/transport/http/conversation/handler.go b/backend/internal/transport/http/conversation/handler.go index fff8609e..baa52c5a 100644 --- a/backend/internal/transport/http/conversation/handler.go +++ b/backend/internal/transport/http/conversation/handler.go @@ -129,6 +129,15 @@ func mapStreamError(err error) streamError { case errors.Is(err, appconversation.ErrTooManySelectedTools): status = http.StatusBadRequest message = "too many selected tools" + case errors.Is(err, appconversation.ErrTooManySelectedSkills): + status = http.StatusBadRequest + message = "too many selected skills" + case errors.Is(err, appconversation.ErrSkillNotFound): + status = http.StatusNotFound + message = "skill not found" + case errors.Is(err, appconversation.ErrInvalidSkillUse): + status = http.StatusBadRequest + message = "invalid skill use" case errors.Is(err, appconversation.ErrFileProcessingNotReady): status = http.StatusBadRequest message = "file processing not ready" diff --git a/backend/internal/transport/http/conversation/handler_message_send.go b/backend/internal/transport/http/conversation/handler_message_send.go index 40c01e10..4790c924 100644 --- a/backend/internal/transport/http/conversation/handler_message_send.go +++ b/backend/internal/transport/http/conversation/handler_message_send.go @@ -93,6 +93,7 @@ func (h *Handler) parseSendMessageInput(c *gin.Context) (appconversation.SendMes ClientRunID: req.ClientRunID, FileIDs: req.FileIDs, SelectedToolIDs: req.SelectedToolIDs, + SkillIDs: req.SkillIDs, HTMLVisualPromptEnabled: req.HTMLVisualPromptEnabled, HTMLVisualColorMode: req.HTMLVisualColorMode, ParentMessagePublicID: req.ParentMessagePublicID, @@ -286,6 +287,12 @@ func handleSendMessageError(c *gin.Context, err error) { response.Error(c, http.StatusBadRequest, "too many files in one message") case errors.Is(err, appconversation.ErrTooManySelectedTools): response.Error(c, http.StatusBadRequest, "too many selected tools") + case errors.Is(err, appconversation.ErrTooManySelectedSkills): + response.Error(c, http.StatusBadRequest, "too many selected skills") + case errors.Is(err, appconversation.ErrSkillNotFound): + response.Error(c, http.StatusNotFound, "skill not found") + case errors.Is(err, appconversation.ErrInvalidSkillUse): + response.Error(c, http.StatusBadRequest, "invalid skill use") case errors.Is(err, appconversation.ErrInvalidMessageBranch): response.Error(c, http.StatusBadRequest, "invalid message branch") case errors.Is(err, appconversation.ErrFileProcessingNotReady): diff --git a/backend/internal/transport/http/promptpreset/dto.go b/backend/internal/transport/http/promptpreset/dto.go index 41c539d8..2c2b4f42 100644 --- a/backend/internal/transport/http/promptpreset/dto.go +++ b/backend/internal/transport/http/promptpreset/dto.go @@ -34,20 +34,20 @@ type PromptPresetDeleteDataResponse struct { // WritePromptPresetRequest 表示创建预制提示词请求。 type WritePromptPresetRequest struct { - Title string `json:"title" binding:"required,max=16"` - Trigger string `json:"trigger" binding:"required,max=16"` - Description string `json:"description" binding:"max=64"` - Content string `json:"content" binding:"required,max=16384"` + Title string `json:"title" binding:"required,max=64"` + Trigger string `json:"trigger" binding:"required,max=64"` + Description string `json:"description" binding:"max=256"` + Content string `json:"content" binding:"required,max=10000"` Enabled bool `json:"enabled"` SortOrder int `json:"sortOrder"` } // PatchPromptPresetRequest 表示更新预制提示词请求。 type PatchPromptPresetRequest struct { - Title *string `json:"title" binding:"omitempty,max=16"` - Trigger *string `json:"trigger" binding:"omitempty,max=16"` - Description *string `json:"description" binding:"omitempty,max=64"` - Content *string `json:"content" binding:"omitempty,max=16384"` + Title *string `json:"title" binding:"omitempty,max=64"` + Trigger *string `json:"trigger" binding:"omitempty,max=64"` + Description *string `json:"description" binding:"omitempty,max=256"` + Content *string `json:"content" binding:"omitempty,max=10000"` Enabled *bool `json:"enabled"` SortOrder *int `json:"sortOrder"` } diff --git a/backend/internal/transport/http/server.go b/backend/internal/transport/http/server.go index 41b6694d..aa057e99 100644 --- a/backend/internal/transport/http/server.go +++ b/backend/internal/transport/http/server.go @@ -25,6 +25,7 @@ import ( "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" promptpresethttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/promptpreset" settingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/settings" + skillhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/skill" userhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/user" usersettingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/usersettings" "github.com/gin-gonic/gin" @@ -59,6 +60,7 @@ type Modules struct { Admin *adminhttp.Module Announcement *announcementhttp.Module PromptPreset *promptpresethttp.Module + Skill *skillhttp.Module Settings *settingshttp.Module User *userhttp.Module UserSettings *usersettingshttp.Module @@ -158,6 +160,9 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.PromptPreset != nil { modules.PromptPreset.RegisterRoutes(authRequired) } + if modules.Skill != nil { + modules.Skill.RegisterRoutes(authRequired) + } if modules.UserSettings != nil { modules.UserSettings.RegisterRoutes(authRequired) } @@ -167,7 +172,7 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.User != nil { modules.User.RegisterRoutes(authRequired) } - if modules.Admin != nil || modules.Auth != nil || modules.Billing != nil || modules.Channel != nil || modules.MCP != nil || modules.Settings != nil || modules.Announcement != nil || modules.PromptPreset != nil { + if modules.Admin != nil || modules.Auth != nil || modules.Billing != nil || modules.Channel != nil || modules.MCP != nil || modules.Settings != nil || modules.Announcement != nil || modules.PromptPreset != nil || modules.Skill != nil { adminGroup := authRequired.Group("/admin") adminGroup.Use(middleware.AdminOnly()) if modules.Auth != nil { @@ -194,6 +199,9 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.PromptPreset != nil { modules.PromptPreset.RegisterAdminRoutes(adminGroup) } + if modules.Skill != nil { + modules.Skill.RegisterAdminRoutes(adminGroup) + } } if modules.StartupLog != nil { diff --git a/backend/internal/transport/http/skill/dto.go b/backend/internal/transport/http/skill/dto.go new file mode 100644 index 00000000..b2257bc0 --- /dev/null +++ b/backend/internal/transport/http/skill/dto.go @@ -0,0 +1,148 @@ +package skill + +import ( + "time" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +// SkillResponse 表示技能响应。 +type SkillResponse struct { + ID uint `json:"id"` + Scope string `json:"scope"` + Title string `json:"title"` + Trigger string `json:"trigger"` + Description string `json:"description"` + Markdown string `json:"markdown"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedByUserID uint `json:"createdByUserID"` + UpdatedByUserID uint `json:"updatedByUserID"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SkillSummaryResponse 表示技能发现列表响应,不包含 SKILL.md 内容。 +type SkillSummaryResponse struct { + ID uint `json:"id"` + Scope string `json:"scope"` + Title string `json:"title"` + Trigger string `json:"trigger"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SkillDataResponse 包裹单条技能响应。 +type SkillDataResponse struct { + Skill SkillResponse `json:"skill"` +} + +// SkillDeleteDataResponse 表示删除响应。 +type SkillDeleteDataResponse struct { + Deleted bool `json:"deleted"` +} + +// WriteSkillRequest 表示创建技能请求。 +type WriteSkillRequest struct { + Title string `json:"title" binding:"required,max=64"` + Trigger string `json:"trigger" binding:"required,max=64"` + Description string `json:"description" binding:"max=256"` + Markdown string `json:"markdown" binding:"required,max=10000"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` +} + +// PatchSkillRequest 表示更新技能请求。 +type PatchSkillRequest struct { + Title *string `json:"title" binding:"omitempty,max=64"` + Trigger *string `json:"trigger" binding:"omitempty,max=64"` + Description *string `json:"description" binding:"omitempty,max=256"` + Markdown *string `json:"markdown" binding:"omitempty,max=10000"` + Enabled *bool `json:"enabled"` + SortOrder *int `json:"sortOrder"` +} + +// SkillSummaryPageResponseDoc 用于 Swagger 展示技能发现分页响应。 +type SkillSummaryPageResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []SkillSummaryResponse `json:"results"` + } `json:"data"` +} + +// SkillPageResponseDoc 用于 Swagger 展示完整分页响应。 +type SkillPageResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []SkillResponse `json:"results"` + } `json:"data"` +} + +// SkillResponseDoc 用于 Swagger 展示单条响应。 +type SkillResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data SkillDataResponse `json:"data"` +} + +// SkillDeleteResponseDoc 用于 Swagger 展示删除响应。 +type SkillDeleteResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data SkillDeleteDataResponse `json:"data"` +} + +// ErrorDoc 表示错误响应。 +type ErrorDoc struct { + ErrorMsg string `json:"errorMsg"` +} + +func toSkillSummaryResponses(items []domainskill.Skill) []SkillSummaryResponse { + results := make([]SkillSummaryResponse, 0, len(items)) + for _, item := range items { + results = append(results, toSkillSummaryResponse(item)) + } + return results +} + +func toSkillSummaryResponse(item domainskill.Skill) SkillSummaryResponse { + return SkillSummaryResponse{ + ID: item.ID, + Scope: item.Scope, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func toSkillResponses(items []domainskill.Skill) []SkillResponse { + results := make([]SkillResponse, 0, len(items)) + for _, item := range items { + results = append(results, toSkillResponse(item)) + } + return results +} + +func toSkillResponse(item domainskill.Skill) SkillResponse { + return SkillResponse{ + ID: item.ID, + Scope: item.Scope, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Markdown: item.Markdown, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} diff --git a/backend/internal/transport/http/skill/dto_test.go b/backend/internal/transport/http/skill/dto_test.go new file mode 100644 index 00000000..918b2f6c --- /dev/null +++ b/backend/internal/transport/http/skill/dto_test.go @@ -0,0 +1,46 @@ +package skill + +import ( + "encoding/json" + "strings" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +func TestSkillSummaryResponseDoesNotExposeMarkdown(t *testing.T) { + payload, err := json.Marshal(toSkillSummaryResponse(domainskill.Skill{ + ID: 1, + Scope: domainskill.ScopeBuiltin, + Title: "Review", + Trigger: "review", + Description: "Review code", + Markdown: "private SKILL.md", + Enabled: true, + })) + if err != nil { + t.Fatalf("marshal summary response: %v", err) + } + text := string(payload) + if strings.Contains(text, "markdown") || strings.Contains(text, "private SKILL.md") { + t.Fatalf("summary response exposed markdown: %s", text) + } +} + +func TestSkillResponseExposesMarkdownForDetail(t *testing.T) { + payload, err := json.Marshal(toSkillResponse(domainskill.Skill{ + ID: 1, + Scope: domainskill.ScopeBuiltin, + Title: "Review", + Trigger: "review", + Markdown: "private SKILL.md", + Enabled: true, + })) + if err != nil { + t.Fatalf("marshal detail response: %v", err) + } + text := string(payload) + if !strings.Contains(text, `"markdown":"private SKILL.md"`) { + t.Fatalf("detail response did not expose markdown: %s", text) + } +} diff --git a/backend/internal/transport/http/skill/handler.go b/backend/internal/transport/http/skill/handler.go new file mode 100644 index 00000000..c21c97ad --- /dev/null +++ b/backend/internal/transport/http/skill/handler.go @@ -0,0 +1,390 @@ +package skill + +import ( + "errors" + "net/http" + "strconv" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +// Handler 封装技能 HTTP 处理。 +type Handler struct { + service *appskill.Service +} + +// NewHandler 创建技能处理器。 +func NewHandler(service *appskill.Service) *Handler { + return &Handler{service: service} +} + +// ListVisibleSkills godoc +// @Summary 查询当前用户可用技能 +// @Description 返回管理员内置和当前用户自定义的已启用技能摘要,用于会话按需选择 Skill 上下文 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillSummaryPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills [get] +func (h *Handler) ListVisibleSkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListVisible(c.Request.Context(), middleware.MustUserID(c), appskill.ListInput{ + Query: c.Query("q"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillSummaryResponses(items)) +} + +// GetVisibleSkill godoc +// @Summary 查询当前用户可用技能详情 +// @Description 按需返回单个可用 Skill 的完整 SKILL.md 内容,用于用户查看详情 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/{id} [get] +func (h *Handler) GetVisibleSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + item, err := h.service.ResolveAvailable(c.Request.Context(), middleware.MustUserID(c), id) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// ListMySkills godoc +// @Summary 查询我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param enabled query bool false "是否启用" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine [get] +func (h *Handler) ListMySkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListMine(c.Request.Context(), middleware.MustUserID(c), appskill.ListInput{ + Query: c.Query("q"), + Enabled: boolQuery(c, "enabled"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillResponses(items)) +} + +// CreateMySkill godoc +// @Summary 创建我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body WriteSkillRequest true "技能配置" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine [post] +func (h *Handler) CreateMySkill(c *gin.Context) { + var req WriteSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.CreateUser(c.Request.Context(), middleware.MustUserID(c), writeInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// PatchMySkill godoc +// @Summary 更新我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Param body body PatchSkillRequest true "更新字段" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine/{id} [patch] +func (h *Handler) PatchMySkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + var req PatchSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateUser(c.Request.Context(), middleware.MustUserID(c), id, patchInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// DeleteMySkill godoc +// @Summary 删除我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillDeleteResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine/{id} [delete] +func (h *Handler) DeleteMySkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + if err := h.service.DeleteUser(c.Request.Context(), middleware.MustUserID(c), id); err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDeleteDataResponse{Deleted: true}) +} + +// ListAdminSkills godoc +// @Summary 管理员查询内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param enabled query bool false "是否启用" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills [get] +func (h *Handler) ListAdminSkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListAdminBuiltin(c.Request.Context(), appskill.ListInput{ + Query: c.Query("q"), + Enabled: boolQuery(c, "enabled"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillResponses(items)) +} + +// CreateAdminSkill godoc +// @Summary 管理员创建内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body WriteSkillRequest true "技能配置" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills [post] +func (h *Handler) CreateAdminSkill(c *gin.Context) { + var req WriteSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + userID := middleware.MustUserID(c) + item, err := h.service.CreateBuiltin(c.Request.Context(), userID, writeInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.create_builtin", item.ID, map[string]interface{}{"trigger": item.Trigger})) + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// PatchAdminSkill godoc +// @Summary 管理员更新内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Param body body PatchSkillRequest true "更新字段" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills/{id} [patch] +func (h *Handler) PatchAdminSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + var req PatchSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateBuiltin(c.Request.Context(), middleware.MustUserID(c), id, patchInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.update_builtin", item.ID, map[string]interface{}{"trigger": item.Trigger})) + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// DeleteAdminSkill godoc +// @Summary 管理员删除内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillDeleteResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills/{id} [delete] +func (h *Handler) DeleteAdminSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + if err := h.service.DeleteBuiltin(c.Request.Context(), middleware.MustUserID(c), id); err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.delete_builtin", id, nil)) + response.Success(c, SkillDeleteDataResponse{Deleted: true}) +} + +func writeInputFromRequest(req WriteSkillRequest) appskill.WriteInput { + return appskill.WriteInput{ + Title: req.Title, + Trigger: req.Trigger, + Description: req.Description, + Markdown: req.Markdown, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + } +} + +func patchInputFromRequest(req PatchSkillRequest) appskill.PatchInput { + return appskill.PatchInput{ + Title: req.Title, + Trigger: req.Trigger, + Description: req.Description, + Markdown: req.Markdown, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + } +} + +func idParam(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalid skill id") + return 0, false + } + return uint(id), true +} + +func boolQuery(c *gin.Context, key string) *bool { + raw := c.Query(key) + if raw == "" { + return nil + } + parsed, err := strconv.ParseBool(raw) + if err != nil { + return nil + } + return &parsed +} + +func pageParams(c *gin.Context) (int, int) { + page := 1 + pageSize := 20 + const maxPageSize = 100 + if raw := c.Query("page"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + page = parsed + } + } + if raw := c.Query("page_size"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + pageSize = parsed + } + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func auditInput(c *gin.Context, action string, resourceID uint, detail interface{}) appskill.AuditInput { + return appskill.AuditInput{ + UserID: middleware.MustUserID(c), + RequestID: middleware.MustRequestID(c), + Action: action, + ResourceID: strconv.FormatUint(uint64(resourceID), 10), + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Detail: detail, + } +} + +func writeSkillError(c *gin.Context, err error) { + if errors.Is(err, appskill.ErrSkillNotFound) { + response.Error(c, http.StatusNotFound, "skill not found") + return + } + if errors.Is(err, appskill.ErrSkillConflict) { + response.Error(c, http.StatusConflict, "skill trigger already exists") + return + } + if errors.Is(err, appskill.ErrInvalidSkill) { + response.ErrorFrom(c, http.StatusBadRequest, err) + return + } + response.Error(c, http.StatusInternalServerError, "skill operation failed") +} diff --git a/backend/internal/transport/http/skill/module.go b/backend/internal/transport/http/skill/module.go new file mode 100644 index 00000000..fc9391cc --- /dev/null +++ b/backend/internal/transport/http/skill/module.go @@ -0,0 +1,11 @@ +package skill + +// Module 聚合技能 HTTP 处理器。 +type Module struct { + Handler *Handler +} + +// NewModule 创建技能 HTTP 模块。 +func NewModule(handler *Handler) *Module { + return &Module{Handler: handler} +} diff --git a/backend/internal/transport/http/skill/router.go b/backend/internal/transport/http/skill/router.go new file mode 100644 index 00000000..28bddba9 --- /dev/null +++ b/backend/internal/transport/http/skill/router.go @@ -0,0 +1,21 @@ +package skill + +import "github.com/gin-gonic/gin" + +// RegisterRoutes 注册技能用户侧路由。 +func (m *Module) RegisterRoutes(authRequired *gin.RouterGroup) { + authRequired.GET("/skills", m.Handler.ListVisibleSkills) + authRequired.GET("/skills/mine", m.Handler.ListMySkills) + authRequired.POST("/skills/mine", m.Handler.CreateMySkill) + authRequired.PATCH("/skills/mine/:id", m.Handler.PatchMySkill) + authRequired.DELETE("/skills/mine/:id", m.Handler.DeleteMySkill) + authRequired.GET("/skills/:id", m.Handler.GetVisibleSkill) +} + +// RegisterAdminRoutes 注册技能管理路由。 +func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { + adminGroup.GET("/skills", m.Handler.ListAdminSkills) + adminGroup.POST("/skills", m.Handler.CreateAdminSkill) + adminGroup.PATCH("/skills/:id", m.Handler.PatchAdminSkill) + adminGroup.DELETE("/skills/:id", m.Handler.DeleteAdminSkill) +} diff --git a/backend/internal/transport/http/skill/router_test.go b/backend/internal/transport/http/skill/router_test.go new file mode 100644 index 00000000..9b230b72 --- /dev/null +++ b/backend/internal/transport/http/skill/router_test.go @@ -0,0 +1,72 @@ +package skill + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +type routeSkillRepo struct { + items []domainskill.Skill +} + +func (r routeSkillRepo) ListSkills(context.Context, repository.SkillListFilter, int, int) ([]domainskill.Skill, int64, error) { + return r.items, int64(len(r.items)), nil +} + +func (r routeSkillRepo) GetSkill(context.Context, uint) (*domainskill.Skill, error) { + return nil, repository.ErrNotFound +} + +func (r routeSkillRepo) CreateSkill(context.Context, *domainskill.Skill) (*domainskill.Skill, error) { + return nil, repository.ErrInvalidInput +} + +func (r routeSkillRepo) PatchSkill(context.Context, uint, repository.SkillPatch) (*domainskill.Skill, error) { + return nil, repository.ErrInvalidInput +} + +func (r routeSkillRepo) DeleteSkill(context.Context, uint) error { + return repository.ErrInvalidInput +} + +func TestSkillMineRouteIsNotCapturedByVisibleDetailRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.ContextKeyUserID, uint(7)) + c.Next() + }) + + module := NewModule(NewHandler(appskill.NewService(routeSkillRepo{ + items: []domainskill.Skill{{ + ID: 1, + Scope: domainskill.ScopeUser, + OwnerUserID: 7, + Title: "Review", + Trigger: "review", + Markdown: "private SKILL.md", + Enabled: true, + }}, + }))) + module.RegisterRoutes(router.Group("")) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/skills/mine", nil) + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected /skills/mine to return 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "private SKILL.md") { + t.Fatalf("expected mine route to return full skill payload, got %s", recorder.Body.String()) + } +} diff --git a/frontend/features/admin/components/sections/conversation/conversation-prompt-presets.tsx b/frontend/features/admin/components/sections/conversation/conversation-prompt-presets.tsx index f053272f..8b0f94b0 100644 --- a/frontend/features/admin/components/sections/conversation/conversation-prompt-presets.tsx +++ b/frontend/features/admin/components/sections/conversation/conversation-prompt-presets.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { FileBox, Plus, Trash2 } from "lucide-react"; +import { Box, FileBox, Plus, Trash2 } from "lucide-react"; import { useLocale } from "next-intl"; import { useTranslations } from "next-intl"; @@ -31,6 +31,7 @@ import { InputGroupInput, } from "@/components/ui/input-group"; import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Table, TableBody, @@ -45,25 +46,45 @@ import { TablePagination, TableToolbar } from "@/components/ui/table-tools"; import { Textarea } from "@/components/ui/textarea"; import { useVirtualTableRows, VirtualTablePaddingRow } from "@/components/ui/virtual-table"; import { SettingsSection } from "@/shared/components/settings-layout"; +import { useAdminSkills } from "@/features/admin/hooks/use-admin-skills"; import { useAdminPromptPresets } from "@/features/admin/hooks/use-admin-prompt-presets"; import { formatDateTime } from "@/features/admin/utils/account-display"; import type { PromptPresetDTO } from "@/shared/api/prompt-presets.types"; +import type { SkillDTO } from "@/shared/api/skills.types"; import { PROMPT_PRESET_LIMITS } from "@/shared/model/prompt-presets"; +import { SKILL_LIMITS } from "@/shared/model/skills"; const PROMPT_PRESET_TABLE_COLUMN_COUNT = 6; +type PromptLibraryType = "prompts" | "skills"; -function PromptPresetsTable({ +type PromptLibraryRow = { + id: number; + title: string; + trigger: string; + description: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +}; + +function PromptLibraryTable({ + emptyLabel, + getSummary, + icon: Icon, items, loading, - onEdit, onDelete, + onEdit, onEnabledChange, }: { - items: PromptPresetDTO[]; + emptyLabel: string; + getSummary: (item: T) => string; + icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; + items: T[]; loading: boolean; - onEdit: (item: PromptPresetDTO) => void; - onDelete: (item: PromptPresetDTO) => void; - onEnabledChange: (item: PromptPresetDTO, checked: boolean) => void; + onDelete: (item: T) => void; + onEdit: (item: T) => void; + onEnabledChange: (item: T, checked: boolean) => void; }) { const t = useTranslations("adminPrompts"); const locale = useLocale(); @@ -92,19 +113,19 @@ function PromptPresetsTable({ {initialLoading ? : null} {items.length === 0 && !loading ? ( - {t("empty")} + {emptyLabel} ) : ( <> {virtualRows.rows.map(({ item }) => { const displayName = item.trigger || item.title; - const summary = item.description || item.content; + const summary = getSummary(item); return ( onEdit(item)}>
    - + {displayName}
    @@ -156,58 +177,93 @@ function PromptPresetsTable({ export function ConversationPromptPresetsSection() { const t = useTranslations("adminPrompts"); + const [activeType, setActiveType] = React.useState("skills"); const prompts = useAdminPromptPresets(); + const skills = useAdminSkills(); + const activeLoading = activeType === "skills" ? skills.loading : prompts.loading; + const activeQuery = activeType === "skills" ? skills.query : prompts.query; + const activePage = activeType === "skills" ? skills.page : prompts.page; + const activePageCount = activeType === "skills" ? skills.pageCount : prompts.pageCount; + const activePageSize = activeType === "skills" ? skills.pageSize : prompts.pageSize; + const activeTotal = activeType === "skills" ? skills.total : prompts.total; + const activeSearchPlaceholder = activeType === "skills" ? t("skillsSearchPlaceholder") : t("searchPlaceholder"); + const activeCreateLabel = activeType === "skills" ? t("createSkill") : t("create"); return ( <>
    +
    + setActiveType(value as PromptLibraryType)}> + + {t("types.skills")} + {t("types.prompts")} + + +
    + void prompts.load()} + query={activeQuery} + queryPlaceholder={activeSearchPlaceholder} + onQueryChange={activeType === "skills" ? skills.setQuery : prompts.setQuery} + loading={activeLoading} + onRefresh={() => void (activeType === "skills" ? skills.load() : prompts.load())} > - void prompts.toggleEnabled(target, checked)} - /> + {activeType === "skills" ? ( + + emptyLabel={t("skillsEmpty")} + getSummary={(item: SkillDTO) => item.description || item.markdown} + icon={Box} + items={skills.items} + loading={skills.loading} + onEdit={skills.openEdit} + onDelete={skills.setDeleteTarget} + onEnabledChange={(target, checked) => void skills.toggleEnabled(target, checked)} + /> + ) : ( + + emptyLabel={t("empty")} + getSummary={(item: PromptPresetDTO) => item.description || item.content} + icon={FileBox} + items={prompts.items} + loading={prompts.loading} + onEdit={prompts.openEdit} + onDelete={prompts.setDeleteTarget} + onEnabledChange={(target, checked) => void prompts.toggleEnabled(target, checked)} + /> + )}
    !prompts.saving && prompts.setDialogOpen(open)}> - - + + {prompts.form.id ? t("editTitle") : t("createTitle")} {t("dialogDescription")} -
    +

    {t("fields.name")}

    @@ -232,7 +288,7 @@ export function ConversationPromptPresetsSection() {

    {t("fields.content")}