diff --git a/frontend/bun.lock b/frontend/bun.lock
index affc1b2..7ab3989 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -5,6 +5,8 @@
"": {
"name": "frontend",
"dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
@@ -216,10 +218,14 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
+ "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
+
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
+
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
@@ -228,6 +234,8 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
+
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
diff --git a/frontend/package.json b/frontend/package.json
index a88914c..c59719f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,8 @@
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
},
"dependencies": {
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
diff --git a/frontend/src/components/RepositoryConfigCell.tsx b/frontend/src/components/RepositoryConfigCell.tsx
new file mode 100644
index 0000000..67fac1a
--- /dev/null
+++ b/frontend/src/components/RepositoryConfigCell.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import { useState } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Settings, FileText } from 'lucide-react';
+import { toast } from 'sonner';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import api from '@/lib/api';
+import type { Repository } from '@/services/repositoryService';
+
+interface RepositoryConfigCellProps {
+ repo: Repository;
+}
+
+export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
+ const queryClient = useQueryClient();
+ const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
+ const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
+
+ const promptMutation = useMutation({
+ mutationFn: async (prompt: string) => {
+ const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
+ project_review_prompt: prompt,
+ });
+ return data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['repositories'] });
+ setIsPromptDialogOpen(false);
+ toast.success(`已更新 ${repo.name} 的项目级提示词`);
+ },
+ onError: (error: Error) => {
+ toast.error(`更新失败: ${error.message}`);
+ },
+ });
+
+ const handleSavePrompt = () => {
+ promptMutation.mutate(draftPrompt.trim());
+ };
+
+ const handleOpenDialog = () => {
+ setDraftPrompt(repo.project_review_prompt ?? '');
+ setIsPromptDialogOpen(true);
+ };
+
+ const hasPrompt = !!repo.project_review_prompt?.trim();
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/RepositoryManager.tsx b/frontend/src/components/RepositoryManager.tsx
index 6768164..6a2a577 100644
--- a/frontend/src/components/RepositoryManager.tsx
+++ b/frontend/src/components/RepositoryManager.tsx
@@ -17,17 +17,17 @@ function DataTableSkeleton() {
-
-
-
+
+
+
{Array.from({ length: 10 }).map((_, i) => (
-
-
+
+
))}
diff --git a/frontend/src/components/RepositoryTableColumns.tsx b/frontend/src/components/RepositoryTableColumns.tsx
index 108f2c8..c60b9f8 100644
--- a/frontend/src/components/RepositoryTableColumns.tsx
+++ b/frontend/src/components/RepositoryTableColumns.tsx
@@ -2,42 +2,34 @@
import type { ColumnDef } from "@tanstack/react-table"
import type { Repository } from "@/services/repositoryService"
-import { WebhookToggleButton } from "@/components/WebhookToggleButton"
+import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
+import { WebhookToggleCell } from "@/components/WebhookToggleCell"
export const columns: ColumnDef[] = [
{
accessorKey: "name",
header: "仓库名称",
- cell: ({ row }) => {row.getValue("name")}
,
+ cell: ({ row }) => (
+
+ {row.getValue("name")}
+
+ ),
},
{
accessorKey: "webhook_status",
- header: "Webhook 状态",
+ header: "Webhook",
cell: ({ row }) => {
- const status = row.getValue("webhook_status") as Repository["webhook_status"]
- const isActive = status === 'active'
- return (
-
- {isActive && }
- {isActive ? '已启用' : '未启用'}
-
- )
+ const repo = row.original
+ return
},
},
{
id: "actions",
- header: () => 操作
,
- cell: ({ row }) => {
- const repo = row.original
- return (
-
-
-
- )
- },
+ header: () => 提示词
,
+ cell: ({ row }) => (
+
+
+
+ ),
},
]
diff --git a/frontend/src/components/WebhookToggleButton.tsx b/frontend/src/components/WebhookToggleButton.tsx
deleted file mode 100644
index 54ef190..0000000
--- a/frontend/src/components/WebhookToggleButton.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useMutation, useQueryClient } from '@tanstack/react-query';
-import api from '@/lib/api';
-import { Loader2 } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { toast } from "sonner";
-
-interface WebhookToggleButtonProps {
- repoName: string;
- status: 'active' | 'inactive';
- hookId: number | null;
-}
-
-const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
-const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
-
-export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
- const queryClient = useQueryClient();
-
- const mutation = useMutation({
- mutationFn: status === 'active'
- ? () => deleteWebhook({ repoName, hookId: hookId! })
- : () => createWebhook(repoName),
- onSuccess: () => {
- // 操作成功后,使仓库列表的查询失效,React Query会自动重新获取最新数据
- queryClient.invalidateQueries({ queryKey: ['repositories'] });
- toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
- },
- onError: (error) => {
- console.error("操作失败:", error);
- toast.error(`Operation failed: ${error.message}`);
- },
- });
-
- return (
-
- );
-}
diff --git a/frontend/src/components/WebhookToggleCell.tsx b/frontend/src/components/WebhookToggleCell.tsx
new file mode 100644
index 0000000..34850cd
--- /dev/null
+++ b/frontend/src/components/WebhookToggleCell.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2 } from 'lucide-react';
+import { toast } from 'sonner';
+import { Switch } from '@/components/ui/switch';
+import api from '@/lib/api';
+import type { Repository } from '@/services/repositoryService';
+
+interface WebhookToggleCellProps {
+ repo: Repository;
+}
+
+const createWebhook = (repoName: string) =>
+ api.post(`/repositories/${repoName}/webhook`);
+const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
+ api.delete(`/repositories/${repoName}/webhook/${hookId}`);
+
+export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
+ const queryClient = useQueryClient();
+ const isActive = repo.webhook_status === 'active';
+
+ const webhookMutation = useMutation({
+ mutationFn: async () => {
+ if (isActive && repo.hook_id) {
+ return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
+ }
+ return createWebhook(repo.name);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['repositories'] });
+ const action = isActive ? '已禁用' : '已启用';
+ toast.success(`${repo.name} 的 Webhook ${action}`);
+ },
+ onError: (error: Error) => {
+ toast.error(`操作失败: ${error.message}`);
+ },
+ });
+
+ return (
+
+ {webhookMutation.isPending ? (
+
+ ) : (
+ webhookMutation.mutate()}
+ disabled={webhookMutation.isPending}
+ aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
+ />
+ )}
+
+ {isActive ? '已启用' : '未启用'}
+
+
+ );
+}
diff --git a/frontend/src/components/__tests__/RepositoryConfigCell.test.tsx b/frontend/src/components/__tests__/RepositoryConfigCell.test.tsx
new file mode 100644
index 0000000..f3ee834
--- /dev/null
+++ b/frontend/src/components/__tests__/RepositoryConfigCell.test.tsx
@@ -0,0 +1,77 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ReactNode } from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { RepositoryConfigCell } from '../RepositoryConfigCell';
+import type { Repository } from '@/services/repositoryService';
+
+const apiMocks = vi.hoisted(() => ({
+ put: vi.fn(),
+}));
+
+vi.mock('@/lib/api', () => ({
+ default: apiMocks,
+}));
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+function renderWithQuery(ui: ReactNode) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return render({ui});
+}
+
+function makeRepo(overrides: Partial = {}): Repository {
+ return {
+ name: 'demo-owner/demo-repo',
+ webhook_status: 'inactive',
+ hook_id: null,
+ project_review_prompt: null,
+ ...overrides,
+ };
+}
+
+describe('RepositoryConfigCell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('opens prompt dialog and saves project prompt', async () => {
+ apiMocks.put.mockResolvedValueOnce({
+ data: {
+ success: true,
+ project_review_prompt: 'focus null safety',
+ },
+ });
+
+ const user = userEvent.setup();
+ renderWithQuery();
+
+ await user.click(screen.getByRole('button', { name: /配置/i }));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
+
+ const textarea = screen.getByRole('textbox');
+ await user.type(textarea, ' focus null safety ');
+ await user.click(screen.getByRole('button', { name: '保存' }));
+
+ await waitFor(() => {
+ expect(apiMocks.put).toHaveBeenCalledWith(
+ '/repositories/demo-owner/demo-repo/project-prompt',
+ { project_review_prompt: 'focus null safety' }
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/WebhookToggleCell.test.tsx b/frontend/src/components/__tests__/WebhookToggleCell.test.tsx
new file mode 100644
index 0000000..c02aede
--- /dev/null
+++ b/frontend/src/components/__tests__/WebhookToggleCell.test.tsx
@@ -0,0 +1,84 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ReactNode } from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { WebhookToggleCell } from '../WebhookToggleCell';
+import type { Repository } from '@/services/repositoryService';
+
+const apiMocks = vi.hoisted(() => ({
+ post: vi.fn(),
+ delete: vi.fn(),
+}));
+
+vi.mock('@/lib/api', () => ({
+ default: apiMocks,
+}));
+
+vi.mock('sonner', () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+function renderWithQuery(ui: ReactNode) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return render({ui});
+}
+
+function makeRepo(overrides: Partial = {}): Repository {
+ return {
+ name: 'demo-owner/demo-repo',
+ webhook_status: 'inactive',
+ hook_id: null,
+ project_review_prompt: null,
+ ...overrides,
+ };
+}
+
+describe('WebhookToggleCell', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('toggles webhook via switch to enable', async () => {
+ apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
+
+ const user = userEvent.setup();
+ renderWithQuery();
+
+ const switchEl = screen.getByRole('switch');
+ expect(switchEl).toHaveAttribute('aria-checked', 'false');
+ expect(screen.getByText('未启用')).toBeInTheDocument();
+
+ await user.click(switchEl);
+
+ await waitFor(() => {
+ expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
+ });
+ });
+
+ it('toggles webhook via switch to disable', async () => {
+ apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
+
+ const user = userEvent.setup();
+ renderWithQuery();
+
+ const switchEl = screen.getByRole('switch');
+ expect(switchEl).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByText('已启用')).toBeInTheDocument();
+
+ await user.click(switchEl);
+
+ await waitFor(() => {
+ expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
+ });
+ });
+});
diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..f39d4b6
--- /dev/null
+++ b/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..f69a0d6
--- /dev/null
+++ b/frontend/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/frontend/src/services/repositoryService.ts b/frontend/src/services/repositoryService.ts
index 3351839..905b729 100644
--- a/frontend/src/services/repositoryService.ts
+++ b/frontend/src/services/repositoryService.ts
@@ -4,6 +4,7 @@ export interface Repository {
name: string;
webhook_status: 'active' | 'inactive';
hook_id: number | null;
+ project_review_prompt: string | null;
}
export interface PaginatedRepositories {
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
});
return data;
};
+
+export const updateRepositoryProjectPrompt = async (
+ repoName: string,
+ projectReviewPrompt: string
+): Promise<{ success: boolean; project_review_prompt: string | null }> => {
+ const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
+ project_review_prompt: projectReviewPrompt,
+ });
+ return data;
+};
diff --git a/frontend/tests/visual/fixtures/mockApi.ts b/frontend/tests/visual/fixtures/mockApi.ts
index 8385c7e..b4b3326 100644
--- a/frontend/tests/visual/fixtures/mockApi.ts
+++ b/frontend/tests/visual/fixtures/mockApi.ts
@@ -2,8 +2,18 @@ import type { Page, Route } from '@playwright/test';
const repositories = {
data: [
- { name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 },
- { name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null },
+ {
+ name: 'demo-repo-1',
+ webhook_status: 'active',
+ hook_id: 101,
+ project_review_prompt: '重点检查 API 错误处理与鉴权边界。',
+ },
+ {
+ name: 'demo-repo-2',
+ webhook_status: 'inactive',
+ hook_id: null,
+ project_review_prompt: null,
+ },
],
totalCount: 2,
page: 1,
@@ -296,14 +306,27 @@ export async function installVisualApiMocks(page: Page) {
return json(route, repositories);
}
- if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) {
+ if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook$/.test(path)) {
return json(route, { hook_id: 101, webhook_status: 'active' });
}
- if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) {
+ if (
+ method === 'DELETE' &&
+ /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook\/\d+$/.test(path)
+ ) {
return route.fulfill({ status: 204, body: '' });
}
+ if (
+ method === 'PUT' &&
+ /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/project-prompt$/.test(path)
+ ) {
+ return json(route, {
+ success: true,
+ project_review_prompt: 'updated prompt',
+ });
+ }
+
if (method === 'GET' && path.endsWith('/admin/api/config')) {
return json(route, configResponse);
}
diff --git a/src/controllers/__tests__/admin-repositories.test.ts b/src/controllers/__tests__/admin-repositories.test.ts
new file mode 100644
index 0000000..96fa5ed
--- /dev/null
+++ b/src/controllers/__tests__/admin-repositories.test.ts
@@ -0,0 +1,86 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { Hono } from 'hono';
+import { repositoryReviewPromptRepo } from '../../db/repositories/repository-review-prompt-repo';
+import { giteaService } from '../../services/gitea';
+import { adminController } from '../admin';
+
+type RepoRecord = { full_name: string };
+type HookRecord = { id: number; config: { url: string } };
+
+function createTestApp(): Hono {
+ const app = new Hono();
+ app.route('/admin/api', adminController.protectedRoutes);
+ return app;
+}
+
+describe('admin repositories route', () => {
+ const originalListAllRepositories = giteaService.listAllRepositories;
+ const originalListWebhooks = giteaService.listWebhooks;
+ const originalListProjectPrompts = repositoryReviewPromptRepo.listProjectPrompts;
+
+ beforeEach(() => {
+ const repos: RepoRecord[] = [
+ { full_name: 'team/inactive-alpha' },
+ { full_name: 'team/active-beta' },
+ { full_name: 'team/inactive-gamma' },
+ { full_name: 'team/active-delta' },
+ ];
+
+ giteaService.listAllRepositories = async () => ({
+ repos,
+ totalCount: repos.length,
+ });
+
+ giteaService.listWebhooks = async (_owner: string, repo: string) => {
+ if (repo.startsWith('active-')) {
+ return [{ id: 101, config: { url: 'http://localhost/webhook/gitea' } }] as HookRecord[];
+ }
+ return [] as HookRecord[];
+ };
+
+ repositoryReviewPromptRepo.listProjectPrompts = () => ({
+ 'team/active-beta': 'focus security',
+ });
+ });
+
+ afterEach(() => {
+ giteaService.listAllRepositories = originalListAllRepositories;
+ giteaService.listWebhooks = originalListWebhooks;
+ repositoryReviewPromptRepo.listProjectPrompts = originalListProjectPrompts;
+ });
+
+ test('returns active webhook repositories first', async () => {
+ const app = createTestApp();
+ const response = await app.request('http://localhost/admin/api/repositories?page=1');
+ const payload = (await response.json()) as {
+ data: Array<{
+ name: string;
+ webhook_status: 'active' | 'inactive';
+ hook_id: number | null;
+ project_review_prompt: string | null;
+ }>;
+ totalCount: number;
+ page: number;
+ limit: number;
+ };
+
+ expect(response.status).toBe(200);
+ expect(payload.totalCount).toBe(4);
+ expect(payload.page).toBe(1);
+ expect(payload.limit).toBe(30);
+ expect(payload.data.map((repo) => repo.name)).toEqual([
+ 'team/active-beta',
+ 'team/active-delta',
+ 'team/inactive-alpha',
+ 'team/inactive-gamma',
+ ]);
+ expect(payload.data.map((repo) => repo.webhook_status)).toEqual([
+ 'active',
+ 'active',
+ 'inactive',
+ 'inactive',
+ ]);
+ expect(payload.data[0]?.project_review_prompt).toBe('focus security');
+ expect(payload.data[1]?.project_review_prompt).toBeNull();
+ });
+});
diff --git a/src/controllers/admin.ts b/src/controllers/admin.ts
index d7c71b1..973fc46 100644
--- a/src/controllers/admin.ts
+++ b/src/controllers/admin.ts
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
import config from '../config';
+import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
import { reviewEngine } from '../review/engine';
import { giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
@@ -37,6 +38,10 @@ protectedRoutes.get('/repositories', async (c) => {
const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query);
const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea');
+ const fullNames = repos
+ .map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null))
+ .filter((name): name is string => name !== null);
+ const promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
const reposWithStatus = await Promise.all(
repos.map(async (repo) => {
@@ -47,10 +52,18 @@ protectedRoutes.get('/repositories', async (c) => {
name: repo.full_name,
webhook_status: webhook ? 'active' : 'inactive',
hook_id: webhook ? webhook.id : null,
+ project_review_prompt: promptMap[repo.full_name] || null,
};
})
);
+ reposWithStatus.sort((a, b) => {
+ if (a.webhook_status === b.webhook_status) {
+ return 0;
+ }
+ return a.webhook_status === 'active' ? -1 : 1;
+ });
+
return c.json({
data: reposWithStatus,
totalCount,
@@ -63,6 +76,29 @@ protectedRoutes.get('/repositories', async (c) => {
}
});
+protectedRoutes.put('/repositories/:owner/:repo/project-prompt', async (c) => {
+ const { owner, repo } = c.req.param();
+
+ try {
+ const body = (await c.req.json()) as { project_review_prompt?: unknown };
+ if (typeof body.project_review_prompt !== 'string') {
+ return c.json({ message: 'project_review_prompt must be a string' }, 400);
+ }
+
+ const normalizedPrompt = body.project_review_prompt.trim();
+ if (!normalizedPrompt) {
+ repositoryReviewPromptRepo.clearProjectPrompt(owner, repo);
+ return c.json({ success: true, project_review_prompt: null });
+ }
+
+ const updated = repositoryReviewPromptRepo.setProjectPrompt(owner, repo, normalizedPrompt);
+ return c.json({ success: true, project_review_prompt: updated.project_prompt });
+ } catch (error: any) {
+ logger.error(`更新 ${owner}/${repo} 的项目级审查提示词失败:`, error);
+ return c.json({ message: 'Failed to update project review prompt', error: error.message }, 500);
+ }
+});
+
// 创建 Webhook
protectedRoutes.post('/repositories/:owner/:repo/webhook', async (c) => {
const { owner, repo } = c.req.param();
diff --git a/src/db/__tests__/repository-review-prompt-repo.test.ts b/src/db/__tests__/repository-review-prompt-repo.test.ts
new file mode 100644
index 0000000..cc82e38
--- /dev/null
+++ b/src/db/__tests__/repository-review-prompt-repo.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { randomUUID } from 'node:crypto';
+import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { closeDatabase, initDatabase } from '../database';
+import { repositoryReviewPromptRepo } from '../repositories/repository-review-prompt-repo';
+
+describe('repository-review-prompt-repo', () => {
+ let dbPath: string;
+ const savedDbPath = process.env.DATABASE_PATH;
+
+ beforeEach(() => {
+ const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
+ mkdirSync(tmpDir, { recursive: true });
+ dbPath = join(tmpDir, 'test.db');
+ process.env.DATABASE_PATH = dbPath;
+ initDatabase();
+ });
+
+ afterEach(() => {
+ closeDatabase();
+ if (savedDbPath === undefined) {
+ Reflect.deleteProperty(process.env, 'DATABASE_PATH');
+ } else {
+ process.env.DATABASE_PATH = savedDbPath;
+ }
+
+ if (existsSync(dbPath)) unlinkSync(dbPath);
+ if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
+ if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
+ });
+
+ test('sets and gets project prompt by owner/repo', () => {
+ repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on API correctness');
+
+ const prompt = repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant');
+ expect(prompt).toBe('focus on API correctness');
+ });
+
+ test('normalizes surrounding whitespace when setting prompt', () => {
+ repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', ' use chinese output ');
+
+ const row = repositoryReviewPromptRepo.getByFullName('acme/assistant');
+ expect(row?.project_prompt).toBe('use chinese output');
+ });
+
+ test('clears prompt for repository', () => {
+ repositoryReviewPromptRepo.setProjectPrompt('acme', 'assistant', 'focus on security');
+ const deleted = repositoryReviewPromptRepo.clearProjectPrompt('acme', 'assistant');
+
+ expect(deleted).toBe(true);
+ expect(repositoryReviewPromptRepo.getProjectPrompt('acme', 'assistant')).toBeUndefined();
+ });
+
+ test('lists prompt map for repository names', () => {
+ repositoryReviewPromptRepo.setProjectPrompt('acme', 'a', 'prompt-a');
+ repositoryReviewPromptRepo.setProjectPrompt('acme', 'b', 'prompt-b');
+
+ const map = repositoryReviewPromptRepo.listProjectPrompts(['acme/a', 'acme/b', 'acme/c']);
+
+ expect(map).toEqual({
+ 'acme/a': 'prompt-a',
+ 'acme/b': 'prompt-b',
+ });
+ });
+});
diff --git a/src/db/database.ts b/src/db/database.ts
index 07bba5c..2ad2fb4 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -11,6 +11,7 @@ import { dirname, resolve } from 'node:path';
import { migration001Init } from './migrations/001_init';
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
+import { migration003RepositoryReviewPrompts } from './migrations/003_repository_review_prompts';
// ---------------------------------------------------------------------------
// Types
@@ -26,7 +27,11 @@ export interface Migration {
// Migration registry (ordered by version)
// ---------------------------------------------------------------------------
-const MIGRATIONS: Migration[] = [migration001Init, migration002RemoveLegacyReviewMode];
+const MIGRATIONS: Migration[] = [
+ migration001Init,
+ migration002RemoveLegacyReviewMode,
+ migration003RepositoryReviewPrompts,
+];
// ---------------------------------------------------------------------------
// Database singleton
diff --git a/src/db/migrations/003_repository_review_prompts.ts b/src/db/migrations/003_repository_review_prompts.ts
new file mode 100644
index 0000000..9949b52
--- /dev/null
+++ b/src/db/migrations/003_repository_review_prompts.ts
@@ -0,0 +1,21 @@
+import type { Database } from 'bun:sqlite';
+import type { Migration } from '../database';
+
+export const migration003RepositoryReviewPrompts: Migration = {
+ version: 3,
+ name: 'add_repository_review_prompts',
+
+ up(db: Database): void {
+ db.exec(`
+ CREATE TABLE repository_review_prompts (
+ full_name TEXT PRIMARY KEY,
+ project_prompt TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+
+ db.exec(
+ 'CREATE INDEX idx_repository_review_prompts_updated_at ON repository_review_prompts(updated_at)'
+ );
+ },
+};
diff --git a/src/db/repositories/repository-review-prompt-repo.ts b/src/db/repositories/repository-review-prompt-repo.ts
new file mode 100644
index 0000000..dcffa46
--- /dev/null
+++ b/src/db/repositories/repository-review-prompt-repo.ts
@@ -0,0 +1,94 @@
+import { getDatabase } from '../database';
+
+export interface RepositoryReviewPromptRow {
+ full_name: string;
+ project_prompt: string;
+ updated_at: string;
+}
+
+function toFullName(owner: string, repo: string): string {
+ return `${owner}/${repo}`;
+}
+
+export const repositoryReviewPromptRepo = {
+ getByFullName(fullName: string): RepositoryReviewPromptRow | null {
+ const db = getDatabase();
+ return (
+ (db
+ .query(
+ 'SELECT full_name, project_prompt, updated_at FROM repository_review_prompts WHERE full_name = ?'
+ )
+ .get(fullName) as RepositoryReviewPromptRow | null) || null
+ );
+ },
+
+ getProjectPrompt(owner: string, repo: string): string | undefined {
+ const row = this.getByFullName(toFullName(owner, repo));
+ if (!row) return undefined;
+ const normalized = row.project_prompt.trim();
+ return normalized.length > 0 ? normalized : undefined;
+ },
+
+ upsertByFullName(fullName: string, projectPrompt: string): RepositoryReviewPromptRow {
+ const db = getDatabase();
+ const normalized = projectPrompt.trim();
+ if (!normalized) {
+ throw new Error('projectPrompt must be non-empty');
+ }
+
+ db.query(
+ `INSERT INTO repository_review_prompts (full_name, project_prompt, updated_at)
+ VALUES (?, ?, datetime('now'))
+ ON CONFLICT(full_name) DO UPDATE SET
+ project_prompt = excluded.project_prompt,
+ updated_at = datetime('now')`
+ ).run(fullName, normalized);
+
+ const row = this.getByFullName(fullName);
+ if (!row) {
+ throw new Error('Failed to load repository review prompt after upsert');
+ }
+ return row;
+ },
+
+ setProjectPrompt(owner: string, repo: string, projectPrompt: string): RepositoryReviewPromptRow {
+ return this.upsertByFullName(toFullName(owner, repo), projectPrompt);
+ },
+
+ deleteByFullName(fullName: string): boolean {
+ const db = getDatabase();
+ const result = db
+ .query('DELETE FROM repository_review_prompts WHERE full_name = ?')
+ .run(fullName);
+ return result.changes > 0;
+ },
+
+ clearProjectPrompt(owner: string, repo: string): boolean {
+ return this.deleteByFullName(toFullName(owner, repo));
+ },
+
+ listProjectPrompts(fullNames: string[]): Record {
+ if (fullNames.length === 0) {
+ return {};
+ }
+
+ const db = getDatabase();
+ const placeholders = fullNames.map(() => '?').join(', ');
+ const rows = db
+ .query(
+ `SELECT full_name, project_prompt
+ FROM repository_review_prompts
+ WHERE full_name IN (${placeholders})`
+ )
+ .all(...fullNames) as Array>;
+
+ const map: Record = {};
+ for (const row of rows) {
+ const normalized = row.project_prompt.trim();
+ if (normalized) {
+ map[row.full_name] = normalized;
+ }
+ }
+ return map;
+ },
+};
diff --git a/src/review/__tests__/project-prompt-wiring.test.ts b/src/review/__tests__/project-prompt-wiring.test.ts
new file mode 100644
index 0000000..fa2f137
--- /dev/null
+++ b/src/review/__tests__/project-prompt-wiring.test.ts
@@ -0,0 +1,244 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import type { DiffExtractor } from '../context/diff-extractor';
+import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
+import type { FileReviewStore } from '../store/file-review-store';
+import type { Finding, ReviewContext, ReviewRun, ReviewTask } from '../types';
+
+function makeRun(overrides: Partial = {}): ReviewRun {
+ return {
+ id: 'run-project-prompt',
+ idempotencyKey: 'owner/repo#8:base...head',
+ eventType: 'pull_request',
+ status: 'in_progress',
+ owner: 'owner',
+ repo: 'repo',
+ cloneUrl: 'https://example.com/repo.git',
+ prNumber: 8,
+ baseSha: 'base-sha',
+ headSha: 'head-sha',
+ attempts: 1,
+ maxAttempts: 3,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+function createStoreMock() {
+ return {
+ markRunIgnored: mock(async () => undefined),
+ addStep: mock(async () => undefined),
+ getRunDetails: mock(async () => ({ comments: [], findings: [] })),
+ addFindings: mock(async () => undefined),
+ markFindingPublished: mock(async () => true),
+ addCommentRecord: mock(async () => undefined),
+ };
+}
+
+function createLocalRepoManagerMock() {
+ const repoPaths: LocalRepoPaths = {
+ mirrorPath: '/tmp/mirror',
+ workspacePath: '/tmp/workspace',
+ };
+
+ return {
+ manager: {
+ prepareWorkspace: mock(async () => repoPaths),
+ resolveReviewedRef: mock(async () => null),
+ saveReviewedRef: mock(async () => undefined),
+ cleanupWorkspace: mock(async () => undefined),
+ },
+ repoPaths,
+ };
+}
+
+function createDiffExtractorMock() {
+ const context: ReviewContext = {
+ workspacePath: '/tmp/workspace',
+ mirrorPath: '/tmp/mirror',
+ diff: 'diff --git a/src/app.ts b/src/app.ts\n+const x = 1;',
+ changedFiles: [
+ {
+ path: 'src/app.ts',
+ status: 'M',
+ additions: 3,
+ deletions: 1,
+ },
+ ],
+ parsedDiff: [],
+ fileContents: {},
+ };
+
+ return {
+ context,
+ extractor: {
+ getSandbox: mock(() => ({
+ execute: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
+ })),
+ buildContext: mock(async () => context),
+ },
+ };
+}
+
+describe('project prompt wiring', () => {
+ beforeEach(() => {
+ mock.restore();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ test('orchestrator forwards resolved project prompt to triage and specialist execution options', async () => {
+ const projectPrompt = `repo-policy-${'P'.repeat(360)}`;
+
+ mock.module('../project-review-prompt', () => ({
+ resolveProjectReviewPrompt: () => projectPrompt,
+ }));
+
+ const { ReviewOrchestrator } = await import('../orchestrator');
+
+ const store = createStoreMock();
+ const { manager } = createLocalRepoManagerMock();
+ const { extractor } = createDiffExtractorMock();
+
+ const orchestrator = new ReviewOrchestrator(
+ store as unknown as FileReviewStore,
+ manager as unknown as LocalRepoManager,
+ extractor as unknown as DiffExtractor
+ );
+
+ type TriageResultLike = {
+ complexity: 'trivial' | 'standard' | 'complex';
+ reviewSize: 'small' | 'medium' | 'large';
+ mode: 'skip' | 'light' | 'full';
+ tasks: ReviewTask[];
+ riskTags: string[];
+ rationale: string;
+ };
+
+ type ReviewFinding = Array>;
+
+ type InternalOrchestrator = {
+ triageAgent: {
+ analyze: (
+ context: ReviewContext,
+ options?: { projectPrompt?: string }
+ ) => Promise;
+ };
+ agentMap: Record<
+ string,
+ {
+ reviewWithOptions: (
+ run: ReviewRun,
+ context: ReviewContext,
+ options: { projectPrompt?: string }
+ ) => Promise<{ findings: ReviewFinding }>;
+ reviewWithReflection: (
+ run: ReviewRun,
+ context: ReviewContext,
+ maxRounds?: number,
+ options?: { projectPrompt?: string }
+ ) => Promise<{ findings: ReviewFinding }>;
+ }
+ >;
+ judgeAgent: {
+ judge: (findings: ReviewFinding) => { summaryMarkdown: string; findings: ReviewFinding };
+ };
+ publishSummary: (run: ReviewRun, summary: string, gatedCount: number) => Promise;
+ publishLineComments: (
+ run: ReviewRun,
+ comments: Array<{ path: string; line: number; comment: string }>
+ ) => Promise;
+ };
+
+ const internal = orchestrator as unknown as InternalOrchestrator;
+
+ const task: ReviewTask = {
+ domain: 'correctness',
+ paths: ['src/app.ts'],
+ riskTags: [],
+ mode: 'light',
+ tokenBudget: 1200,
+ maxIterations: 1,
+ allowTools: false,
+ allowReflection: false,
+ allowDebate: false,
+ };
+
+ const triageAnalyzeMock = mock(async () => ({
+ complexity: 'standard' as const,
+ reviewSize: 'small' as const,
+ mode: 'light' as const,
+ tasks: [task],
+ riskTags: [],
+ rationale: 'project prompt wiring test',
+ }));
+
+ const reviewWithOptionsMock = mock(async () => ({
+ findings: [] as ReviewFinding,
+ }));
+
+ const reviewWithReflectionMock = mock(async () => ({
+ findings: [] as ReviewFinding,
+ }));
+
+ internal.triageAgent = {
+ analyze: triageAnalyzeMock,
+ };
+
+ internal.agentMap = {
+ correctness: {
+ reviewWithOptions: reviewWithOptionsMock,
+ reviewWithReflection: reviewWithReflectionMock,
+ },
+ };
+
+ internal.judgeAgent = {
+ judge: mock(() => ({
+ summaryMarkdown: 'ok',
+ findings: [] as ReviewFinding,
+ })),
+ };
+
+ internal.publishSummary = mock(async () => undefined);
+ internal.publishLineComments = mock(async () => false);
+
+ const run = makeRun();
+ await orchestrator.execute(run);
+
+ expect(triageAnalyzeMock).toHaveBeenCalledWith(expect.anything(), { projectPrompt });
+ expect(reviewWithOptionsMock).toHaveBeenCalledWith(
+ run,
+ expect.anything(),
+ expect.objectContaining({ projectPrompt })
+ );
+ });
+
+ test('codex prompt builder includes resolved project-level prompt section', async () => {
+ const projectPrompt = `codex-policy-${'X'.repeat(320)}`;
+
+ mock.module('../project-review-prompt', () => ({
+ resolveProjectReviewPrompt: () => projectPrompt,
+ }));
+
+ const { CodexRunner } = await import('../codex/codex-runner');
+
+ const store = createStoreMock();
+ const { manager } = createLocalRepoManagerMock();
+
+ const runner = new CodexRunner(
+ store as unknown as FileReviewStore,
+ manager as unknown as LocalRepoManager
+ );
+
+ const internal = runner as unknown as {
+ buildReviewPrompt: (run: ReviewRun, lastReviewedHead?: string) => string;
+ };
+
+ const prompt = internal.buildReviewPrompt(makeRun(), undefined);
+
+ expect(prompt).toContain('## 项目级审查要求');
+ expect(prompt).toContain(projectPrompt);
+ });
+});
diff --git a/src/review/__tests__/triage-agent.test.ts b/src/review/__tests__/triage-agent.test.ts
index 028221f..418cf30 100644
--- a/src/review/__tests__/triage-agent.test.ts
+++ b/src/review/__tests__/triage-agent.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from 'bun:test';
+import type { LLMGateway } from '../../llm/gateway';
import type { LLMChatResponse, ModelRole } from '../../llm/types';
import { TriageAgent } from '../agents/triage-agent';
import type { ChangedFile, FindingCategory, ReviewContext } from '../types';
@@ -204,6 +205,44 @@ describe('TriageAgent task-based routing', () => {
expect(result.rationale).toBe('跨文件业务逻辑调整');
});
+ test('LLM fallback: planner system message keeps full project prompt', async () => {
+ const longProjectPrompt = `repo-policy-${'P'.repeat(420)}`;
+ const { gateway, getCalls } = createMockGateway(async () =>
+ makeChatResponse(
+ JSON.stringify({
+ complexity: 'standard',
+ review_size: 'medium',
+ mode: 'light',
+ relevant_domains: ['correctness'],
+ risk_tags: ['maintainability-hotspot'],
+ rationale: '需要模型判断',
+ })
+ )
+ );
+
+ const agent = new TriageAgent(gateway as unknown as LLMGateway);
+
+ await agent.analyze(
+ makeContext({
+ changedFiles: [
+ makeChangedFile({ path: 'src/service/order.ts', additions: 20, deletions: 10 }),
+ makeChangedFile({ path: 'src/controller/order.ts', additions: 18, deletions: 12 }),
+ makeChangedFile({ path: 'src/repo/order.ts', additions: 15, deletions: 12 }),
+ makeChangedFile({ path: 'src/model/order.ts', additions: 14, deletions: 13 }),
+ ],
+ }),
+ { projectPrompt: longProjectPrompt }
+ );
+
+ const calls = getCalls();
+ expect(calls).toHaveLength(1);
+
+ const plannerMessages = calls[0].request.messages as Array<{ role: string; content: string }>;
+ const plannerSystemMessage = plannerMessages.find((message) => message.role === 'system');
+
+ expect(plannerSystemMessage?.content).toContain(longProjectPrompt);
+ });
+
test('LLM fallback: planner throws -> default full review with all domains', async () => {
const { gateway, getCalls } = createMockGateway(async () => {
throw new Error('planner unavailable');
diff --git a/src/review/agents/critic-agent.ts b/src/review/agents/critic-agent.ts
index 1a86946..347da96 100644
--- a/src/review/agents/critic-agent.ts
+++ b/src/review/agents/critic-agent.ts
@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
-import { withCoreGlobalPrompt } from '../../utils/global-prompt';
+import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import { Finding, ReviewContext } from '../types';
@@ -25,7 +25,8 @@ export class CriticAgent {
async critique(
findings: Omit[],
- context: ReviewContext
+ context: ReviewContext,
+ projectPrompt?: string
): Promise {
if (findings.length === 0) {
return {
@@ -75,7 +76,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system',
content: withCoreGlobalPrompt(
'你是严格的代码审查质量评估专家,以高标准评估findings的质量。',
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -132,7 +133,8 @@ ${tokenCounter.clip(context.diff, 1000)}
async evaluateSingleFinding(
finding: Omit,
- context: ReviewContext
+ context: ReviewContext,
+ projectPrompt?: string
): Promise<{
isValid: boolean;
confidence: number;
@@ -166,7 +168,10 @@ ${tokenCounter.clip(context.diff, 700)}
const messages: LLMMessage[] = [
{
role: 'system',
- content: withCoreGlobalPrompt('你是代码审查质量评估专家。', config.review.globalPrompt),
+ content: withCoreGlobalPrompt(
+ '你是代码审查质量评估专家。',
+ mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
+ ),
},
{ role: 'user', content: prompt },
];
diff --git a/src/review/agents/debate-orchestrator.ts b/src/review/agents/debate-orchestrator.ts
index 75e88a0..299bf76 100644
--- a/src/review/agents/debate-orchestrator.ts
+++ b/src/review/agents/debate-orchestrator.ts
@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
-import { withCoreGlobalPrompt } from '../../utils/global-prompt';
+import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { Finding, FindingSeverity } from '../types';
import { SpecialistAgent } from './specialist-agent';
@@ -24,7 +24,8 @@ export class DebateOrchestrator {
async conductDebate(
finding: Omit,
agents: SpecialistAgent[],
- maxRounds = 2
+ maxRounds = 2,
+ projectPrompt?: string
): Promise> {
if (agents.length < 2) {
logger.debug('Debate需要至少2个agents,跳过');
@@ -41,7 +42,7 @@ export class DebateOrchestrator {
// 收集初始意见
for (const agent of agents) {
- const opinion = await this.getAgentOpinion(agent, finding);
+ const opinion = await this.getAgentOpinion(agent, finding, projectPrompt);
opinions.set((agent as any).agentName, opinion);
}
@@ -55,7 +56,13 @@ export class DebateOrchestrator {
const agentName = (agent as any).agentName;
const otherOpinions = Array.from(opinions.entries()).filter(([name]) => name !== agentName);
- const revisedOpinion = await this.reviseOpinion(agent, finding, otherOpinions, opinions);
+ const revisedOpinion = await this.reviseOpinion(
+ agent,
+ finding,
+ otherOpinions,
+ opinions,
+ projectPrompt
+ );
opinions.set(agentName, revisedOpinion);
}
@@ -75,7 +82,8 @@ export class DebateOrchestrator {
private async getAgentOpinion(
agent: SpecialistAgent,
- finding: Omit
+ finding: Omit,
+ projectPrompt?: string
): Promise {
const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。评估以下代码问题的严重性、置信度和有效性。
@@ -107,7 +115,7 @@ export class DebateOrchestrator {
role: 'system',
content: withCoreGlobalPrompt(
`你是${agentName},从你的专业角度独立评估代码问题。`,
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -153,7 +161,8 @@ export class DebateOrchestrator {
agent: SpecialistAgent,
finding: Omit,
otherOpinions: [string, AgentOpinion][],
- opinions: Map
+ opinions: Map,
+ projectPrompt?: string
): Promise {
const agentName = (agent as any).agentName;
const prompt = `你是${agentName}。重新评估以下问题,考虑其他专家的意见。
@@ -188,7 +197,7 @@ ${otherOpinions
role: 'system',
content: withCoreGlobalPrompt(
`你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`,
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
diff --git a/src/review/agents/reflexion-agent.ts b/src/review/agents/reflexion-agent.ts
index be135ce..2014545 100644
--- a/src/review/agents/reflexion-agent.ts
+++ b/src/review/agents/reflexion-agent.ts
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
-import { withGlobalPrompt } from '../../utils/global-prompt';
+import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import { LearningSystem } from '../learning/learning-system';
@@ -43,6 +43,7 @@ export class ReflexionAgent extends SpecialistAgent {
let bestFindings: Omit[] = [];
let bestQualityScore = 0;
let currentFindings: Omit[] = [];
+ const projectPrompt = options?.projectPrompt;
for (let round = 0; round < maxReflectionRounds; round++) {
logger.info(`${this.agentName} Reflection Round ${round + 1}/${maxReflectionRounds}`, {
@@ -53,7 +54,7 @@ export class ReflexionAgent extends SpecialistAgent {
const draft = await this.generateDraft(run, context, currentFindings, round, options);
// 自我批评
- const critique = await this.criticAgent.critique(draft, context);
+ const critique = await this.criticAgent.critique(draft, context, projectPrompt);
logger.info(`${this.agentName} Critique结果`, {
runId: run.id,
@@ -82,7 +83,7 @@ export class ReflexionAgent extends SpecialistAgent {
// 如果还有改进空间,继续优化(refine后需要在下一轮重新评估)
if (round < maxReflectionRounds - 1) {
- currentFindings = await this.refine(draft, critique, context, run);
+ currentFindings = await this.refine(draft, critique, context, run, projectPrompt);
}
}
@@ -113,7 +114,8 @@ export class ReflexionAgent extends SpecialistAgent {
draft: Omit[],
critique: CritiqueResult,
context: ReviewContext,
- run: ReviewRun
+ run: ReviewRun,
+ projectPrompt?: string
): Promise[]> {
const prompt = `你是${this.agentName}。根据以下批评意见,改进审查结果。
@@ -150,7 +152,7 @@ ${tokenCounter.clip(context.diff, 1000)}
role: 'system',
content: withGlobalPrompt(
`你是${this.agentName},根据批评反馈改进审查结果。`,
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, projectPrompt)
),
},
{ role: 'user', content: prompt },
diff --git a/src/review/agents/specialist-agent.ts b/src/review/agents/specialist-agent.ts
index 470bcf5..c051f06 100644
--- a/src/review/agents/specialist-agent.ts
+++ b/src/review/agents/specialist-agent.ts
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage, LLMToolCall } from '../../llm/types';
-import { withGlobalPrompt } from '../../utils/global-prompt';
+import { mergeReviewPrompts, withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import type { LearningSystem } from '../learning/learning-system';
@@ -36,6 +36,7 @@ export interface SpecialistReviewOptions {
maxIterations?: number;
mode?: ReviewMode;
maxContextTokens?: number;
+ projectPrompt?: string;
}
function toCompactContext(context: ReviewContext, options?: CompactContextOptions): string {
@@ -185,7 +186,7 @@ ${toCompactContext(context, {
role: 'system',
content: withGlobalPrompt(
'你是严格的代码审查专家。返回结构化JSON,不输出额外文字。confidence取值范围0到1。line必须是正整数且引用新增行。',
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
{ role: 'user', content: prompt },
@@ -271,7 +272,7 @@ ${this.toolRegistry!.getAll()
"need_more_investigation": false
}
每个 finding 对象的所有字段都是必填的。无问题时返回空数组 {"findings": [], "need_more_investigation": false}。`,
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
];
diff --git a/src/review/agents/triage-agent.ts b/src/review/agents/triage-agent.ts
index d76f267..7915cfb 100644
--- a/src/review/agents/triage-agent.ts
+++ b/src/review/agents/triage-agent.ts
@@ -12,7 +12,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
-import { withCoreGlobalPrompt } from '../../utils/global-prompt';
+import { mergeReviewPrompts, withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import type {
ChangedFile,
@@ -41,6 +41,10 @@ export interface TriageResult {
rationale: string;
}
+export interface TriageOptions {
+ projectPrompt?: string;
+}
+
/** All valid finding categories. */
const ALL_DOMAINS: FindingCategory[] = [
'correctness',
@@ -334,7 +338,7 @@ export class TriageAgent {
* If the planner role is not configured or the call fails,
* falls back to a heuristic-based triage.
*/
- async analyze(context: ReviewContext): Promise {
+ async analyze(context: ReviewContext, options?: TriageOptions): Promise {
// First try heuristic-based fast path (no LLM call needed for obvious cases)
const heuristicResult = this.heuristicTriage(context.changedFiles);
if (heuristicResult) {
@@ -349,7 +353,7 @@ export class TriageAgent {
// Fall back to LLM-based triage
try {
- return await this.llmTriage(context);
+ return await this.llmTriage(context, options);
} catch (error) {
logger.warn('Triage: LLM 调用失败,回退到启发式全量派发', {
error: error instanceof Error ? error.message : String(error),
@@ -454,7 +458,7 @@ export class TriageAgent {
/**
* LLM-based triage using the 'planner' role.
*/
- private async llmTriage(context: ReviewContext): Promise {
+ private async llmTriage(context: ReviewContext, options?: TriageOptions): Promise {
const policy = getReviewBudgetPolicy();
const riskTags = collectRiskTags(context.changedFiles);
const fileSummary = context.changedFiles
@@ -494,7 +498,7 @@ ${diffPreview}
role: 'system',
content: withCoreGlobalPrompt(
'你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON,不输出额外文字。',
- config.review.globalPrompt
+ mergeReviewPrompts(config.review.globalPrompt, options?.projectPrompt)
),
},
{ role: 'user', content: prompt },
diff --git a/src/review/codex/codex-runner.ts b/src/review/codex/codex-runner.ts
index 2824bc5..8413e3b 100644
--- a/src/review/codex/codex-runner.ts
+++ b/src/review/codex/codex-runner.ts
@@ -3,6 +3,7 @@ import path from 'node:path';
import config from '../../config';
import { logger } from '../../utils/logger';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
+import { resolveProjectReviewPrompt } from '../project-review-prompt';
import type { FileReviewStore } from '../store/file-review-store';
import type { ReviewRun } from '../types';
import { type ReviewRunContext, mcpToolExecutor } from './mcp-tools';
@@ -306,7 +307,7 @@ export class CodexRunner {
}
private resolveProjectPrompt(_run: ReviewRun): string | undefined {
- return undefined;
+ return resolveProjectReviewPrompt(_run.owner, _run.repo);
}
/**
diff --git a/src/review/orchestrator.ts b/src/review/orchestrator.ts
index 9d787aa..7105f61 100644
--- a/src/review/orchestrator.ts
+++ b/src/review/orchestrator.ts
@@ -12,6 +12,7 @@ import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager';
import { LearningSystem } from './learning/learning-system';
import { VectorMemoryStore } from './memory/vector-store';
import { applyPublishPolicy } from './policy/publish-policy';
+import { resolveProjectReviewPrompt } from './project-review-prompt';
import { FileReviewStore } from './store/file-review-store';
import { createCodeSearchTool } from './tools/code-search-tool';
import { createFileReadTool } from './tools/file-read-tool';
@@ -229,6 +230,8 @@ export class ReviewOrchestrator {
return;
}
+ const projectPrompt = resolveProjectReviewPrompt(run.owner, run.repo);
+
// ── Triage: 决定哪些 specialist 需要参与 ─────────────────────────
let triage: TriageResult | null = null;
const enableTriage = config.review.enableTriage ?? true;
@@ -242,7 +245,7 @@ export class ReviewOrchestrator {
startedAt: new Date(triageStart).toISOString(),
});
- triage = await this.triageAgent.analyze(context);
+ triage = await this.triageAgent.analyze(context, { projectPrompt });
await this.store.addStep({
runId: run.id,
@@ -330,6 +333,7 @@ export class ReviewOrchestrator {
maxIterations: task.maxIterations,
mode: task.mode,
maxContextTokens: Math.max(1500, Math.floor(task.tokenBudget * 0.7)),
+ projectPrompt,
} as const;
const useReflection =
@@ -411,7 +415,9 @@ export class ReviewOrchestrator {
const uniqueDebateAgents = [...new Set(debateAgents)];
const debatedFinding = await this.debateOrchestrator.conductDebate(
finding,
- uniqueDebateAgents
+ uniqueDebateAgents,
+ 2,
+ projectPrompt
);
debatedFindings.push(debatedFinding);
}
diff --git a/src/review/project-review-prompt.ts b/src/review/project-review-prompt.ts
new file mode 100644
index 0000000..2a632a1
--- /dev/null
+++ b/src/review/project-review-prompt.ts
@@ -0,0 +1,19 @@
+import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
+import { logger } from '../utils/logger';
+
+export function resolveProjectReviewPrompt(owner: string, repo: string): string | undefined {
+ try {
+ return repositoryReviewPromptRepo.getProjectPrompt(owner, repo);
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Database not initialized')) {
+ return undefined;
+ }
+
+ logger.warn('读取项目级审查提示词失败,回退为仅全局提示词', {
+ owner,
+ repo,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return undefined;
+ }
+}
diff --git a/src/utils/global-prompt.ts b/src/utils/global-prompt.ts
index 7c4dfa6..cd3bb34 100644
--- a/src/utils/global-prompt.ts
+++ b/src/utils/global-prompt.ts
@@ -11,14 +11,32 @@ export function withGlobalPrompt(systemContent: string, globalPrompt: string | u
return `${systemContent}\n\n${globalPrompt}`;
}
+export function mergeReviewPrompts(
+ globalPrompt: string | undefined,
+ projectPrompt: string | undefined
+): string | undefined {
+ const normalized = [globalPrompt, projectPrompt]
+ .map((item) => item?.trim())
+ .filter((item): item is string => !!item && item.length > 0);
+
+ if (normalized.length === 0) {
+ return undefined;
+ }
+
+ return normalized.join('\n\n');
+}
+
export function withCoreGlobalPrompt(
systemContent: string,
globalPrompt: string | undefined,
- maxChars = 240
+ maxChars?: number
): string {
if (!globalPrompt || globalPrompt.trim() === '') {
return systemContent;
}
- const compact = globalPrompt.trim().slice(0, maxChars);
+
+ const normalized = globalPrompt.trim();
+ const compact =
+ typeof maxChars === 'number' && maxChars > 0 ? normalized.slice(0, maxChars) : normalized;
return `${systemContent}\n\n${compact}`;
}