chore: update ji branch work
This commit is contained in:
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({
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+11
-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
|
||||||
|
|||||||
+5
-5
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user