chore: update ji branch work

This commit is contained in:
吉鑫升 committed 2026-06-04 17:46:11 +08:00
1 parent 6c30e649e3
commit cbd07c9d1b
9 files changed
+188 -124

No files matched your search

@@ -222,6 +222,7 @@ export function AgentManageDialog({
key={agent.id} key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`} value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => void add(agent)} onSelect={() => void add(agent)}
className="flex min-w-0 items-center overflow-hidden"
> >
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" /> <BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
@@ -120,7 +120,7 @@ export function AgentSelector({
value={`${agent.name} ${agent.description ?? ""}`} value={`${agent.name} ${agent.description ?? ""}`}
onSelect={() => handleSelect(agent)} onSelect={() => handleSelect(agent)}
className={cn( className={cn(
"flex items-center gap-2", "flex min-w-0 items-center gap-2 overflow-hidden",
agent.id === selectedAgentId && "bg-accent", agent.id === selectedAgentId && "bg-accent",
)} )}
> >
@@ -29,6 +29,7 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useAgents } from "@/core/agents/hooks"; import { useAgents } from "@/core/agents/hooks";
import { getStoredAuth } from "@/core/auth";
import { import {
useCreateCustomPrompt, useCreateCustomPrompt,
useCustomPrompts, useCustomPrompts,
@@ -42,16 +43,6 @@ import {
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Sentinel select value for "no agent association" (global prompt). */
const NO_AGENT = "__global__";
/**
* Bar above the chat input showing the user's saved prompts as centered,
* clickable chips. When there are too many to fit, they become a horizontal
* carousel (arrows appear). A gear pinned to the far right opens the manage
* dialog. Prompts are bound to the account (server-backed), and this coexists
* with the admin-configured global prompt prefix.
*/
export function CustomPromptBar({ export function CustomPromptBar({
onInsert, onInsert,
className, className,
@@ -59,19 +50,13 @@ export function CustomPromptBar({
}: { }: {
onInsert: (content: string) => void; onInsert: (content: string) => void;
className?: string; className?: string;
/**
* The agent currently selected for this chat (or null on the general
* new-chat page). Agent-associated prompts only show when their ``agent_id``
* matches; un-associated (global) prompts always show.
*/
currentAgentId?: string | null; currentAgentId?: string | null;
}) { }) {
const allPrompts = useCustomPrompts(); const allPrompts = useCustomPrompts();
const prompts = allPrompts.filter( const prompts = allPrompts.filter(
(p) => !p.agent_id || p.agent_id === currentAgentId, (prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
); );
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false); const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false); const [canRight, setCanRight] = useState(false);
@@ -87,7 +72,7 @@ export function CustomPromptBar({
updateArrows(); updateArrows();
window.addEventListener("resize", updateArrows); window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows); return () => window.removeEventListener("resize", updateArrows);
}, [updateArrows, prompts]); }, [prompts, updateArrows]);
const scrollByDir = (dir: 1 | -1) => { const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" }); scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
@@ -97,7 +82,7 @@ export function CustomPromptBar({
return ( return (
<> <>
<div className={cn("flex items-center gap-1", className)}> <div className={cn("flex min-w-0 items-center gap-1", className)}>
{prompts.length > 0 && ( {prompts.length > 0 && (
<> <>
{overflowing && ( {overflowing && (
@@ -116,7 +101,7 @@ export function CustomPromptBar({
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={updateArrows} onScroll={updateArrows}
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" className="min-w-0 flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
> >
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5"> <div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
{prompts.map((prompt) => ( {prompts.map((prompt) => (
@@ -150,7 +135,7 @@ export function CustomPromptBar({
)} )}
</> </>
)} )}
{prompts.length === 0 && <div className="flex-1" />} {prompts.length === 0 && <div className="min-w-0 flex-1" />}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -183,34 +168,33 @@ function ManagePromptsDialog({
const { agents } = useAgents(); const { agents } = useAgents();
const preferences = useUserPreferences(); const preferences = useUserPreferences();
const updatePreferences = useUpdateUserPreferences(); const updatePreferences = useUpdateUserPreferences();
const currentUserId = getStoredAuth()?.user_id ?? null;
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [agentId, setAgentId] = useState<string>(NO_AGENT); const [agentId, setAgentId] = useState<string | undefined>(undefined);
const saving = createPrompt.isPending || updatePrompt.isPending; const saving = createPrompt.isPending || updatePrompt.isPending;
const agentNameById = (id?: string | null) =>
id ? agents.find((a) => a.id === id)?.name ?? null : null;
// Only offer the agents the user has favorited (收藏到「我的」) for
// association — the full catalog is huge, mostly irrelevant, and overflowed
// the dropdown. The agent a prompt is *already* bound to is kept in the list
// even if it is no longer a favorite, so editing never silently drops it.
const selectableAgents = useMemo(() => { const selectableAgents = useMemo(() => {
const favorites = agents.filter((a) => a.is_favorite); const owned = currentUserId
if (agentId !== NO_AGENT && !favorites.some((a) => a.id === agentId)) { ? agents.filter((agent) => agent.user_id === currentUserId)
const bound = agents.find((a) => a.id === agentId); : [];
if (bound) return [bound, ...favorites]; if (agentId && !owned.some((agent) => agent.id === agentId)) {
const bound = agents.find((agent) => agent.id === agentId);
if (bound) return [bound, ...owned];
} }
return favorites; return owned;
}, [agents, agentId]); }, [agents, agentId, currentUserId]);
const agentNameById = (id?: string | null) =>
id ? agents.find((agent) => agent.id === id)?.name ?? null : null;
const resetForm = () => { const resetForm = () => {
setEditingId(null); setEditingId(null);
setTitle(""); setTitle("");
setContent(""); setContent("");
setAgentId(NO_AGENT); setAgentId(undefined);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -219,19 +203,23 @@ function ManagePromptsDialog({
toast.error("提示词内容不能为空"); toast.error("提示词内容不能为空");
return; return;
} }
const boundAgentId = agentId === NO_AGENT ? null : agentId; if (!agentId) {
toast.error("请选择关联智能体");
return;
}
try { try {
if (editingId) { if (editingId) {
await updatePrompt.mutateAsync({ await updatePrompt.mutateAsync({
id: editingId, id: editingId,
input: { title: trimmedTitle, content, agent_id: boundAgentId }, input: { title: trimmedTitle, content, agent_id: agentId },
}); });
toast.success("提示词已更新"); toast.success("提示词已更新");
} else { } else {
await createPrompt.mutateAsync({ await createPrompt.mutateAsync({
title: trimmedTitle, title: trimmedTitle,
content, content,
agent_id: boundAgentId, agent_id: agentId,
}); });
toast.success("提示词已添加"); toast.success("提示词已添加");
} }
@@ -242,12 +230,12 @@ function ManagePromptsDialog({
}; };
const handleEdit = (id: string) => { const handleEdit = (id: string) => {
const target = prompts.find((p) => p.id === id); const target = prompts.find((prompt) => prompt.id === id);
if (!target) return; if (!target) return;
setEditingId(id); setEditingId(id);
setTitle(target.title); setTitle(target.title);
setContent(target.content); setContent(target.content);
setAgentId(target.agent_id || NO_AGENT); setAgentId(target.agent_id || undefined);
}; };
const handleToggleHideAdmin = async (next: boolean) => { const handleToggleHideAdmin = async (next: boolean) => {
@@ -262,6 +250,7 @@ function ManagePromptsDialog({
try { try {
await deletePrompt.mutateAsync(id); await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm(); if (editingId === id) resetForm();
toast.success("提示词已删除");
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@@ -279,7 +268,7 @@ function ManagePromptsDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle> <DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription> <DialogDescription>
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。 保存常用提示词后,会显示在输入框上方。提示词与账号绑定,可跨设备使用。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -287,41 +276,44 @@ function ManagePromptsDialog({
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium">只用我自己的提示词</p> <p className="text-sm font-medium">只用我自己的提示词</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
开启后,输入框不再显示管理员配置的提示词(提示词前缀)开关,本账号仅使用自己保存的提示词。 开启后,输入框不再显示管理员配置的提示词前缀开关,本账号仅使用自己保存的提示词。
</p> </p>
</div> </div>
<Switch <Switch
checked={preferences.hide_admin_prompt_prefix} checked={preferences.hide_admin_prompt_prefix}
disabled={updatePreferences.isPending} disabled={updatePreferences.isPending}
onCheckedChange={(v) => void handleToggleHideAdmin(v)} onCheckedChange={(value) => void handleToggleHideAdmin(value)}
/> />
</div> </div>
<div className="space-y-3 rounded-md border p-3"> <div className="space-y-3 rounded-md border p-3">
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(event) => setTitle(event.target.value)}
placeholder="名称(可选,例如:周报总结)" placeholder="名称(可选,例如:周报总结)"
/> />
<Textarea <Textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(event) => setContent(event.target.value)}
placeholder="提示词内容" placeholder="提示词内容"
className="min-h-28" className="min-h-28"
/> />
<div className="flex flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<p className="text-muted-foreground text-xs">关联智能体(可选)</p> <p className="text-muted-foreground text-xs">关联智能体</p>
<Select value={agentId} onValueChange={setAgentId}> <Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"> <SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
<SelectValue placeholder="不关联(所有对话可见)" /> <SelectValue placeholder="请选择已配置的智能体" />
</SelectTrigger> </SelectTrigger>
<SelectContent <SelectContent
position="popper" position="popper"
className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]" className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)] overflow-hidden"
> >
<SelectItem value={NO_AGENT}>不关联(所有对话可见)</SelectItem>
{selectableAgents.map((agent) => ( {selectableAgents.map((agent) => (
<SelectItem key={agent.id} value={agent.id} className="pr-8"> <SelectItem
key={agent.id}
value={agent.id}
className="max-w-full pr-8"
>
<span className="block max-w-full truncate"> <span className="block max-w-full truncate">
{desensitize(agent.name)} {desensitize(agent.name)}
</span> </span>
@@ -331,13 +323,18 @@ function ManagePromptsDialog({
</Select> </Select>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{selectableAgents.length === 0 {selectableAgents.length === 0
? "仅可关联你收藏到「我的」的常用智能体;当前还没有收藏任何智能体。" ? "当前账号还没有已配置的智能体。"
: "关联后,仅在选中该智能体的对话里显示此提示词。这里只列出你收藏的常用智能体。"} : "关联后,仅在选中该智能体的对话里显示此提示词。"}
</p> </p>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{editingId && ( {editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}> <Button
variant="outline"
size="sm"
onClick={resetForm}
disabled={saving}
>
取消编辑 取消编辑
</Button> </Button>
)} )}
@@ -48,6 +48,13 @@ export function useUserPreferences(): UserPreferences {
return data ?? DEFAULT_PREFS; return data ?? DEFAULT_PREFS;
} }
export function useUserPreferencesQuery() {
return useQuery({
queryKey: QUERY_KEY,
queryFn: getUserPreferences,
});
}
export function useUpdateUserPreferences() { export function useUpdateUserPreferences() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+8 -2
View File
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks"; import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { useUserPreferences } from "@/core/user-preferences"; import { useUserPreferencesQuery } from "@/core/user-preferences";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts"; import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import { import {
ChatBox, ChatBox,
@@ -53,7 +53,9 @@ export default function ChatPage() {
const { data: promptPrefixSettings } = usePromptPrefixSettings(); const { data: promptPrefixSettings } = usePromptPrefixSettings();
// Personal opt-out: when set, hide the admin prompt-prefix switch entirely and // Personal opt-out: when set, hide the admin prompt-prefix switch entirely and
// never apply the prefix — the user uses only their own prompts. // never apply the prefix — the user uses only their own prompts.
const { hide_admin_prompt_prefix: hideAdminPrompt } = useUserPreferences(); const { data: userPreferences, isLoading: preferencesLoading } =
useUserPreferencesQuery();
const hideAdminPrompt = userPreferences?.hide_admin_prompt_prefix === true;
const [promptPrefixOn, setPromptPrefixOn] = useState(false); const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
@@ -163,6 +165,7 @@ export default function ChatPage() {
isNewThread && isNewThread &&
!hasStartedConversation && !hasStartedConversation &&
promptPrefixOn && promptPrefixOn &&
!preferencesLoading &&
!hideAdminPrompt && !hideAdminPrompt &&
promptPrefixSettings?.enabled === true; promptPrefixSettings?.enabled === true;
setHasStartedConversation(true); setHasStartedConversation(true);
@@ -195,6 +198,7 @@ export default function ChatPage() {
promptPrefixOn, promptPrefixOn,
promptPrefixSettings, promptPrefixSettings,
hideAdminPrompt, hideAdminPrompt,
preferencesLoading,
selectedAgentId, selectedAgentId,
router, router,
settings.context.writing_mode, settings.context.writing_mode,
@@ -223,6 +227,7 @@ export default function ChatPage() {
const promptPrefixProp = useMemo(() => { const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined; if (!promptPrefixSettings?.enabled) return undefined;
if (preferencesLoading) return undefined;
// Personal opt-out hides the admin prompt-prefix switch entirely. // Personal opt-out hides the admin prompt-prefix switch entirely.
if (hideAdminPrompt) return undefined; if (hideAdminPrompt) return undefined;
// The prefix toggle is offered only on the opening turn of a new thread. // The prefix toggle is offered only on the opening turn of a new thread.
@@ -235,6 +240,7 @@ export default function ChatPage() {
}, [ }, [
promptPrefixSettings, promptPrefixSettings,
hideAdminPrompt, hideAdminPrompt,
preferencesLoading,
isNewThread, isNewThread,
hasStartedConversation, hasStartedConversation,
promptPrefixOn, promptPrefixOn,
@@ -257,6 +257,7 @@ function stripTimestampSuffix(title: string): string {
} }
const BODY_TAB_VALUE = "__body__"; const BODY_TAB_VALUE = "__body__";
const HTML_TAB_VALUE = "__html__";
const RUN_LIST_COLLAPSED_WIDTH = 56; const RUN_LIST_COLLAPSED_WIDTH = 56;
const RUN_LIST_DEFAULT_WIDTH = 380; const RUN_LIST_DEFAULT_WIDTH = 380;
const RUN_LIST_MIN_WIDTH = 280; const RUN_LIST_MIN_WIDTH = 280;
@@ -427,12 +428,8 @@ export default function PublicScheduledTaskPage() {
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out" className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
style={{ width: visibleRunListWidth }} style={{ width: visibleRunListWidth }}
> >
<Card {runListExpanded ? (
className={cn( <Card className="flex min-h-0 w-full flex-col overflow-hidden">
"flex min-h-0 w-full flex-col overflow-hidden",
!runListExpanded && "pointer-events-none [&>*]:invisible",
)}
>
<CardHeader className="gap-1"> <CardHeader className="gap-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<Tooltip> <Tooltip>
@@ -447,9 +444,21 @@ export default function PublicScheduledTaskPage() {
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0"> <div className="flex shrink-0 items-center gap-1">
{task?.enabled ? "启用" : "暂停"} <Badge variant={task?.enabled ? "default" : "secondary"}>
</Badge> {task?.enabled ? "启用" : "暂停"}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary size-7"
onClick={collapseRunList}
title="收起执行记录列表"
aria-label="收起执行记录列表"
>
<ChevronLeft className="size-4" />
</Button>
</div>
</div> </div>
{task?.description && task.description.trim() && ( {task?.description && task.description.trim() && (
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap"> <p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
@@ -552,15 +561,20 @@ export default function PublicScheduledTaskPage() {
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
</Card> </Card>
<button ) : (
type="button" <Card className="flex min-h-0 w-full flex-col items-center overflow-hidden py-3">
onClick={runListExpanded ? collapseRunList : expandRunList} <Button
className="bg-background text-muted-foreground hover:bg-accent hover:text-primary focus-visible:ring-ring absolute top-3 left-1/2 z-30 flex size-9 -translate-x-1/2 items-center justify-center rounded-md border shadow-sm transition-colors focus:outline-none focus-visible:ring-2" size="icon"
title={runListExpanded ? "收起执行记录列表" : "展开执行记录列表"} variant="ghost"
aria-label={runListExpanded ? "收起执行记录列表" : "展开执行记录列表"} className="text-muted-foreground hover:text-primary"
> onClick={expandRunList}
{runListExpanded ? <ChevronLeft className="size-4" /> : <List className="size-4" />} title="展开执行记录列表"
</button> aria-label="展开执行记录列表"
>
<List className="size-4" />
</Button>
</Card>
)}
<div <div
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
@@ -638,12 +652,14 @@ export default function PublicScheduledTaskPage() {
} }
type ContentTab = { type ContentTab = {
/** Stable tab identifier — `__body__` for inline content, `file-<index>` for files. */ /** Stable tab identifier — `__html__` for the HTML preview, `__body__` for
* inline content, `file-<index>` for files. */
value: string; value: string;
/** Short label rendered in the TabsTrigger. */ /** Short label rendered in the TabsTrigger. */
label: string; label: string;
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */ /** Distinguishes the HTML preview vs body vs MD attachment so the renderer /
kind: "body" | "file"; * editor knows what to show and which PUT to call. */
kind: "body" | "file" | "html";
/** File index (when `kind === "file"`). */ /** File index (when `kind === "file"`). */
fileIndex?: number; fileIndex?: number;
/** Filename for downloads (defaults to the run title for body). */ /** Filename for downloads (defaults to the run title for body). */
@@ -684,17 +700,27 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
[run.result?.files], [run.result?.files],
); );
// Tabs: every MD file first (priority per spec), then the inline body. // Tabs: HTML preview first (when present), then every MD file, then the
// inline body. For an HTML run that also produced a Markdown file, this keeps
// the page preview AND surfaces the .md instead of hiding it. The generated
// HTML document itself is never listed as a downloadable "file" tab — it has
// its own preview.
const tabs: ContentTab[] = useMemo(() => { const tabs: ContentTab[] = useMemo(() => {
const list: ContentTab[] = markdownFiles.map((file, idx) => ({ const list: ContentTab[] = [];
value: `file-${file.index}`, if (isHtmlRun) {
// Show the actual filename without the `.md` suffix — the user explicitly list.push({ value: HTML_TAB_VALUE, label: "HTML 预览", kind: "html" });
// asked for "no '文件' label, just the name without extension". }
label: stripExtension(file.name) || `文件 ${idx + 1}`, list.push(
kind: "file", ...markdownFiles.map((file, idx) => ({
fileIndex: file.index, value: `file-${file.index}`,
fileName: file.name, // Show the actual filename without the `.md` suffix — the user explicitly
})); // asked for "no '文件' label, just the name without extension".
label: stripExtension(file.name) || `文件 ${idx + 1}`,
kind: "file" as const,
fileIndex: file.index,
fileName: file.name,
})),
);
list.push({ list.push({
value: BODY_TAB_VALUE, value: BODY_TAB_VALUE,
label: "正文", label: "正文",
@@ -702,7 +728,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
fileName: run.result?.title || "scheduled-task-result", fileName: run.result?.title || "scheduled-task-result",
}); });
return list; return list;
}, [markdownFiles, run.result?.title]); }, [isHtmlRun, markdownFiles, run.result?.title]);
const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE); const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
@@ -843,13 +869,13 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
下载原文件 下载原文件
</a> </a>
)} )}
{!isHtmlRun && ( {activeTabMeta?.kind !== "html" && (
<Button size="sm" variant="outline" onClick={handleDownloadWord}> <Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" /> <FileText className="size-4" />
下载 Word 下载 Word
</Button> </Button>
)} )}
{!isHtmlRun && {activeTabMeta?.kind !== "html" &&
(isEditing ? ( (isEditing ? (
<> <>
<Button <Button
@@ -895,22 +921,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</CardHeader> </CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0"> <CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{isHtmlRun ? ( {tabs.length === 0 ? (
<div className="h-full p-4">
{htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)}
</div>
) : tabs.length === 0 ? (
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div> <div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
) : ( ) : (
<Tabs <Tabs
@@ -940,7 +951,20 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
{run.error} {run.error}
</div> </div>
)} )}
{fileIsLoading ? ( {activeTabMeta?.kind === "html" ? (
htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)
) : fileIsLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm"> <div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
正在加载文件内容... 正在加载文件内容...
@@ -270,7 +270,16 @@ export default function ScheduledTaskRunDetailPage() {
} }
}; };
const resultFiles = useMemo(() => result?.files ?? [], [result]); // The generated HTML document is already shown in the dedicated "HTML 预览"
// tab, so it is excluded from the "结果文件" list — that list surfaces the
// Markdown (and any other non-HTML artifact) only.
const resultFiles = useMemo(
() =>
(result?.files ?? []).filter(
(f) => !(f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html")),
),
[result],
);
const fileCount = resultFiles.length; const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => { const resultMetadata = useMemo(() => {
if (!result) return ""; if (!result) return "";
@@ -295,18 +304,28 @@ export default function ScheduledTaskRunDetailPage() {
} }
setFilesLoading(true); setFilesLoading(true);
try { try {
const loaded = await Promise.all( // Load each file independently. A single failing file (e.g. an archive
// that 404s) must NOT blank the whole panel — otherwise the tab shows
// "结果文件(2)" while the body says "没有生成文件". Render every file
// that loads and skip the ones that don't.
const settled = await Promise.allSettled(
resultFiles.map((f) => { resultFiles.map((f) => {
const index = result.files.indexOf(f); const index = result.files.indexOf(f);
return getScheduledTaskRunFileContent(run.id, index); return getScheduledTaskRunFileContent(run.id, index);
}), }),
); );
if (!cancelled) { if (!cancelled) {
const loaded = settled
.filter(
(r): r is PromiseFulfilledResult<ScheduledTaskResultFileContent> =>
r.status === "fulfilled",
)
.map((r) => r.value);
setFileContents(loaded); setFileContents(loaded);
} const failed = settled.length - loaded.length;
} catch (error) { if (failed > 0 && loaded.length === 0) {
if (!cancelled) { toast.error("结果文件已不可用(可能已被清理)");
toast.error(error instanceof Error ? error.message : "加载文件内容失败"); }
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -584,7 +603,9 @@ export default function ScheduledTaskRunDetailPage() {
)} )}
{!filesLoading && fileContents.length === 0 && ( {!filesLoading && fileContents.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm"> <div className="text-muted-foreground rounded-md border p-3 text-sm">
本次执行没有生成文件。 {fileCount > 0
? `本次执行生成了 ${fileCount} 个文件,但当前无法读取(文件可能已被清理或移动)。可尝试「下载全部」。`
: "本次执行没有生成文件。"}
</div> </div>
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -243,6 +243,17 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
# Get newly uploaded files from the current message's additional_kwargs.files # Get newly uploaded files from the current message's additional_kwargs.files
new_files = self._files_from_kwargs(last_message, uploads_dir) or [] new_files = self._files_from_kwargs(last_message, uploads_dir) or []
# Only inject when the *current* message actually carries new uploads.
# Turns without a fresh upload must not re-inject previously uploaded
# files: the original upload turn already injected (and persisted) their
# <uploaded_files> block, and the files remain physically available for
# read_file/grep/view_image. Re-injecting on every later message made the
# agent re-reference old images/docs, and — since the frontend parses
# files out of the <uploaded_files> content when additional_kwargs.files
# is empty — re-rendered the old attachment on each new question.
if not new_files:
return None
# Collect historical files from the uploads directory (all except the new ones) # Collect historical files from the uploads directory (all except the new ones)
new_filenames = {f["filename"] for f in new_files} new_filenames = {f["filename"] for f in new_files}
historical_files: list[dict] = [] historical_files: list[dict] = []
@@ -270,9 +281,6 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
file["outline"] = outline file["outline"] = outline
file["outline_preview"] = preview file["outline_preview"] = preview
if not new_files and not historical_files:
return None
logger.debug(f"New files: {[f['filename'] for f in new_files]}, historical: {[f['filename'] for f in historical_files]}") logger.debug(f"New files: {[f['filename'] for f in new_files]}, historical: {[f['filename'] for f in historical_files]}")
# Create files message and prepend to the last human message content # Create files message and prepend to the last human message content
@@ -1007,11 +1007,11 @@ class ScheduledTaskService:
if negative_prompt: if negative_prompt:
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}") parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
gather_prompt = "\n".join(parts) gather_prompt = "\n".join(parts)
# When the Markdown switch is also on, ask this same gather pass to save a # When the Markdown switch is also on, this same gather pass must save a
# .md file (the length gate is enforced in code, not surfaced here) so the # .md file. We do NOT append _MARKDOWN_INSTRUCTION here — `_start_agent_run`
# task produces both deliverables in one agent run. # already appends it exactly once when `require_markdown` is present in the
if also_markdown: # execution context (kept below). Appending it here too made the prompt
gather_prompt += _MARKDOWN_INSTRUCTION # carry the 【输出要求】 block twice when both HTML and Markdown were on.
gather_task = {**task, "prompt": gather_prompt} gather_task = {**task, "prompt": gather_prompt}
gather_ctx = dict(task.get("execution_context") or {}) gather_ctx = dict(task.get("execution_context") or {})
# Strip the HTML-specific flags so the gather pass is a plain agent run. # Strip the HTML-specific flags so the gather pass is a plain agent run.