-
+
+ {models.map((model) => (
+
+ ))}
+
该专家被使用时调用的模型。
diff --git a/frontend-web/src/pages/PromptPrefixSettingsPage.tsx b/frontend-web/src/pages/PromptPrefixSettingsPage.tsx
new file mode 100644
index 00000000..1cb7939e
--- /dev/null
+++ b/frontend-web/src/pages/PromptPrefixSettingsPage.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { Loader2Icon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ usePromptPrefixSettings,
+ useUpdatePromptPrefixSettings,
+} from "@/core/system-settings/hooks";
+
+export default function PromptPrefixSettingsPage() {
+ const { data, isLoading, error } = usePromptPrefixSettings();
+ const update = useUpdatePromptPrefixSettings();
+
+ const [enabled, setEnabled] = useState(false);
+ const [switchLabel, setSwitchLabel] = useState("");
+ const [promptPrefix, setPromptPrefix] = useState("");
+ // Only seed the draft from server data once. Re-syncing every time react-query
+ // refetches (window focus, route remount, etc.) would silently overwrite the
+ // user's in-progress edits — and made the toggle look unresponsive: a click
+ // would flip state to true and the next refetch would reset it to false.
+ const initializedRef = useRef(false);
+
+ useEffect(() => {
+ if (!data || initializedRef.current) return;
+ setEnabled(data.enabled);
+ setSwitchLabel(data.switch_label);
+ setPromptPrefix(data.prompt_prefix);
+ initializedRef.current = true;
+ }, [data]);
+
+ const handleSave = async () => {
+ if (enabled && !switchLabel.trim()) {
+ toast.error("开启时必须填写开关名称");
+ return;
+ }
+ if (enabled && !promptPrefix.trim()) {
+ toast.error("开启时必须填写拼接的前缀内容");
+ return;
+ }
+ try {
+ await update.mutateAsync({
+ enabled,
+ switch_label: switchLabel,
+ prompt_prefix: promptPrefix,
+ });
+ toast.success("已保存");
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "保存失败");
+ }
+ };
+
+ return (
+
+
+
问答前缀配置
+
+ 管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
+ 会将下方配置的前缀文本和原问题拼接后再发送给模型。
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ 加载失败:{error.message}
+
+ ) : (
+
+
+
+
向用户暴露开关
+
+ 关闭时,问答页不显示该开关,且后端不会做任何拼接。
+
+
+
+
+
+
+
开关名称
+
+ 展示在问答页输入框附近的标签文本。
+
+
setSwitchLabel(e.target.value)}
+ placeholder="例:加入立场前缀"
+ />
+
+
+
+
拼接内容
+
+ 拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx
index febf21fb..9d47ed62 100644
--- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx
+++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
-import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -23,9 +24,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
+ updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
+import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
@@ -92,6 +95,8 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return
{file.content};
}
+type EditState = { draft: string; saving: boolean };
+
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
@@ -101,6 +106,9 @@ export default function ScheduledTaskRunDetailPage() {
const [fileContents, setFileContents] = useState
([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
+ const [edits, setEdits] = useState>({});
+ const canEditRun = run?.can_edit === true;
+ const canDeleteRun = canEditRun;
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
@@ -170,8 +178,82 @@ export default function ScheduledTaskRunDetailPage() {
}
};
+ const handleDownloadFileAsWord = async (file: ScheduledTaskResultFileContent) => {
+ // Prefer the in-progress draft if the user is editing the file.
+ const source = edits[file.index]?.draft ?? file.content ?? "";
+ if (!source) {
+ toast.error("文件内容未加载,无法转换为 Word");
+ return;
+ }
+ try {
+ await downloadMarkdownAsDocx(source, file.name, result?.title);
+ toast.success("Word 文件已生成");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "生成 Word 失败");
+ }
+ };
+
+ const handleEnterEdit = useCallback((file: ScheduledTaskResultFileContent) => {
+ setEdits((prev) => ({
+ ...prev,
+ [file.index]: { draft: file.content ?? "", saving: false },
+ }));
+ }, []);
+
+ const handleCancelEdit = useCallback((index: number) => {
+ setEdits((prev) => ({ ...prev, [index]: null }));
+ }, []);
+
+ const handleSaveEdit = useCallback(
+ async (file: ScheduledTaskResultFileContent) => {
+ if (!run) return;
+ const current = edits[file.index];
+ if (!current) return;
+ setEdits((prev) => ({
+ ...prev,
+ [file.index]: { ...current, saving: true },
+ }));
+ try {
+ const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
+ setFileContents((prev) =>
+ prev.map((entry) =>
+ entry.index === file.index
+ ? { ...entry, content: current.draft, size: updated.size ?? entry.size }
+ : entry,
+ ),
+ );
+ setEdits((prev) => ({ ...prev, [file.index]: null }));
+ toast.success(`文件 ${file.name} 已保存`);
+ } catch (error) {
+ setEdits((prev) => ({
+ ...prev,
+ [file.index]: { ...current, saving: false },
+ }));
+ toast.error(error instanceof Error ? error.message : "保存失败");
+ }
+ },
+ [edits, run],
+ );
+
+ const handleDownloadBodyAsWord = async () => {
+ if (!result?.content) {
+ toast.error("正文为空,无法转换为 Word");
+ return;
+ }
+ try {
+ await downloadMarkdownAsDocx(
+ result.content,
+ result.title || "scheduled-task-result",
+ result.title || undefined,
+ );
+ toast.success("Word 文件已生成");
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "生成 Word 失败");
+ }
+ };
+
const handleDeleteRun = async () => {
- if (!run) return;
+ if (!run || !canDeleteRun) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
@@ -207,10 +289,23 @@ export default function ScheduledTaskRunDetailPage() {
下载全部
-
{/* 执行信息(可折叠) */}
{showMeta && run && (
@@ -281,26 +376,94 @@ export default function ScheduledTaskRunDetailPage() {