This commit is contained in:
fengxiangdebaozi committed 2026-05-28 20:18:43 +08:00
commit 72eb5204ff
45 files changed
+2355 -847

No files matched your search

@@ -9,6 +9,7 @@ import {
ShieldIcon, ShieldIcon,
SparklesIcon, SparklesIcon,
SquarePenIcon, SquarePenIcon,
StarIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } from "lucide-react";
@@ -45,6 +46,7 @@ import {
useDeleteAgent, useDeleteAgent,
useSetAgentFeatured, useSetAgentFeatured,
useSetAgentPrivate, useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent, useUpdateAgent,
} from "@/core/agents"; } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
@@ -65,6 +67,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const updateAgent = useUpdateAgent(); const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured(); const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate(); const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const { skills, isLoading: isSkillsLoading } = useSkills(); const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -99,6 +102,10 @@ export function AgentCard({ agent }: AgentCardProps) {
// everyone so toggling published is moot. // everyone so toggling published is moot.
const canEdit = isOwned || isAdmin; const canEdit = isOwned || isAdmin;
const canDelete = isOwned || isAdmin; const canDelete = isOwned || isAdmin;
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
useEffect(() => { useEffect(() => {
if (!editOpen) { if (!editOpen) {
@@ -217,6 +224,16 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
} }
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() { async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a // dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -367,8 +384,25 @@ export function AgentCard({ agent }: AgentCardProps) {
)} )}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3"> <CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin) && ( {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1"> <div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"mr-auto h-8 w-8 shrink-0",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
{isAdmin && ( {isAdmin && (
<Button <Button
size="icon" size="icon"
@@ -89,12 +89,15 @@ export function AgentGallery() {
[agents, search], [agents, search],
); );
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
// from the square — favorited public agents have a different user_id but a
// truthy is_favorite flag.
const myAgents = useMemo( const myAgents = useMemo(
() => () =>
agents.filter( agents.filter(
(a) => (a) =>
a.user_id !== null && ((a.user_id !== null && a.user_id === currentUserId) ||
a.user_id === currentUserId && a.is_favorite) &&
matchesSearch(a), matchesSearch(a),
), ),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,294 @@
"use client";
import {
ChevronLeftIcon,
ChevronRightIcon,
PencilIcon,
Settings2Icon,
Trash2Icon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateCustomPrompt,
useCustomPrompts,
useDeleteCustomPrompt,
useUpdateCustomPrompt,
} from "@/core/custom-prompts";
import { cn } from "@/lib/utils";
/**
* 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({
onInsert,
className,
}: {
onInsert: (content: string) => void;
className?: string;
}) {
const prompts = useCustomPrompts();
const [manageOpen, setManageOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanLeft(el.scrollLeft > 4);
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
useEffect(() => {
updateArrows();
window.addEventListener("resize", updateArrows);
return () => window.removeEventListener("resize", updateArrows);
}, [updateArrows, prompts]);
const scrollByDir = (dir: 1 | -1) => {
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
};
const overflowing = canLeft || canRight;
return (
<>
<div className={cn("flex items-center gap-1", className)}>
{prompts.length > 0 && (
<>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canLeft}
onClick={() => scrollByDir(-1)}
aria-label="上一组"
>
<ChevronLeftIcon className="size-4" />
</Button>
)}
<div
ref={scrollRef}
onScroll={updateArrows}
className="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">
{prompts.map((prompt) => (
<button
key={prompt.id}
type="button"
onClick={() => onInsert(prompt.content)}
title={prompt.content}
className={cn(
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
)}
>
{prompt.title || prompt.content || "未命名提示词"}
</button>
))}
</div>
</div>
{overflowing && (
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
disabled={!canRight}
onClick={() => scrollByDir(1)}
aria-label="下一组"
>
<ChevronRightIcon className="size-4" />
</Button>
)}
</>
)}
{prompts.length === 0 && <div className="flex-1" />}
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
title="管理常用提示词"
aria-label="管理常用提示词"
onClick={() => setManageOpen(true)}
>
<Settings2Icon className="size-4" />
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
</>
);
}
function ManagePromptsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const prompts = useCustomPrompts();
const createPrompt = useCreateCustomPrompt();
const updatePrompt = useUpdateCustomPrompt();
const deletePrompt = useDeleteCustomPrompt();
const [editingId, setEditingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const saving = createPrompt.isPending || updatePrompt.isPending;
const resetForm = () => {
setEditingId(null);
setTitle("");
setContent("");
};
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!content.trim()) {
toast.error("提示词内容不能为空");
return;
}
try {
if (editingId) {
await updatePrompt.mutateAsync({
id: editingId,
input: { title: trimmedTitle, content },
});
toast.success("提示词已更新");
} else {
await createPrompt.mutateAsync({ title: trimmedTitle, content });
toast.success("提示词已添加");
}
resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const handleEdit = (id: string) => {
const target = prompts.find((p) => p.id === id);
if (!target) return;
setEditingId(id);
setTitle(target.title);
setContent(target.content);
};
const handleDelete = async (id: string) => {
try {
await deletePrompt.mutateAsync(id);
if (editingId === id) resetForm();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) resetForm();
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-md border p-3">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="名称(可选,例如:周报总结)"
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="提示词内容"
className="min-h-28"
/>
<div className="flex justify-end gap-2">
{editingId && (
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
取消编辑
</Button>
)}
<Button size="sm" onClick={handleSubmit} disabled={saving}>
{editingId ? "保存修改" : "添加提示词"}
</Button>
</div>
</div>
<div className="space-y-2">
{prompts.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
暂无常用提示词
</p>
) : (
prompts.map((prompt) => (
<div
key={prompt.id}
className="flex items-start justify-between gap-2 rounded-md border p-3"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{prompt.title || "未命名提示词"}
</p>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
{prompt.content}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => handleEdit(prompt.id)}
title="编辑"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => void handleDelete(prompt.id)}
title="删除"
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -87,13 +87,14 @@ import {
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "@/core/settings"; } from "@/core/settings";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -304,14 +305,14 @@ export function InputBox({
}); });
}, [context, memoryInjectionEnabled, onContextChange]); }, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
} }
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]); }, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
@@ -324,12 +325,12 @@ export function InputBox({
} }
setFollowups([]); setFollowups([]);
setFollowupsHidden(false); setFollowupsHidden(false);
setFollowupsLoading(false); setFollowupsLoading(false);
if (ragModeEnabled) { if (ragModeEnabled) {
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
// Guard against submitting before the initial model auto-selection // Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state. // effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) { if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.({ onContextChange?.({
@@ -351,13 +352,13 @@ export function InputBox({
onContextChange, onContextChange,
onSubmit, onSubmit,
onStop, onStop,
resolvedModelName, resolvedModelName,
ragModeEnabled, ragModeEnabled,
selectedModel?.supports_thinking, selectedModel?.supports_thinking,
status, status,
threadId, threadId,
], ],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form"); const form = promptRootRef.current?.querySelector("form");
@@ -559,6 +560,21 @@ export function InputBox({
</div> </div>
</div> </div>
)} )}
<div className="flex flex-col gap-1.5">
{isNewThread && (
<CustomPromptBar
className="px-1"
onInsert={(content) => {
const current = textInput.value?.trim() ?? "";
textInput.setInput(current ? `${current}\n${content}` : content);
setTimeout(() => {
document
.querySelector<HTMLTextAreaElement>("textarea[name='message']")
?.focus();
}, 0);
}}
/>
)}
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}> <div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
<PromptInput <PromptInput
className={cn( className={cn(
@@ -651,13 +667,13 @@ export function InputBox({
} }
> >
<PromptInputButton <PromptInputButton
type="button" type="button"
aria-pressed={ragModeEnabled} aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled} disabled={ragModeLocked || disabled}
className={cn( className={cn(
"gap-1.5! rounded-full px-3! shadow-none", "gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80", ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" ? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", : "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)} )}
@@ -1045,6 +1061,7 @@ export function InputBox({
)} )}
</PromptInput> </PromptInput>
</div> </div>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react"; import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import { import {
memo, memo,
useCallback, useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -186,6 +187,7 @@ function MessageContent_({
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins], [baseRehypePlugins, extraRehypePlugins],
); );
const askTimes = useChatAskTimes();
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo( const components = useMemo(
@@ -217,6 +219,11 @@ function MessageContent_({
[threadId, resolveArtifact], [threadId, resolveArtifact],
); );
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message);
@@ -296,6 +303,7 @@ function MessageContent_({
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}
<MessageTimeStamp time={messageTime} align="right" />
</div> </div>
); );
} }
@@ -308,6 +316,7 @@ function MessageContent_({
content={contentToDisplay} content={contentToDisplay}
displaySources={displaySources} displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components} components={components}
/> />
@@ -317,10 +326,57 @@ function MessageContent_({
isLoading={isLoading} isLoading={isLoading}
message={message} message={message}
/> />
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent> </AIElementMessageContent>
); );
} }
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
* timestamp is present.
*/
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return { label, full: date.toLocaleString() };
}
/**
* Small date+time label shown under a message. ``align`` controls the corner:
* ``right`` for the user's own question, ``left`` for the model's answer.
*/
function MessageTimeStamp({
time,
align = "right",
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
)}
title={time.full}
>
<ClockIcon className="size-3 shrink-0" />
<span>{time.label}</span>
</div>
);
}
/** /**
* Get file extension and check helpers * Get file extension and check helpers
*/ */
@@ -493,24 +549,34 @@ function AssistantDisplayContent({
content, content,
displaySources, displaySources,
isLoading, isLoading,
renderSkillDisplayBlocks,
rehypePlugins, rehypePlugins,
components, components,
}: { }: {
content: string; content: string;
displaySources: RuntimeDisplaySource[]; displaySources: RuntimeDisplaySource[];
isLoading: boolean; isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"]; rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"]; components: MarkdownContentProps["components"];
}) { }) {
const { parts, placedKeys } = useMemo( const { parts, placedKeys } = useMemo(
() => splitSkillDisplayPlaceholders(content, displaySources), () =>
[content, displaySources], splitSkillDisplayPlaceholders(
content,
displaySources,
renderSkillDisplayBlocks,
),
[content, displaySources, renderSkillDisplayBlocks],
); );
const fallbackBlocks = useMemo(() => { const fallbackBlocks = useMemo(() => {
if (!renderSkillDisplayBlocks) {
return [];
}
return groupDisplaySources(displaySources).filter( return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key), (group) => group.mode !== "citation" && !placedKeys.has(group.key),
); );
}, [displaySources, placedKeys]); }, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return ( return (
<> <>
@@ -555,6 +621,7 @@ type DisplayPart =
function splitSkillDisplayPlaceholders( function splitSkillDisplayPlaceholders(
content: string, content: string,
displaySources: RuntimeDisplaySource[], displaySources: RuntimeDisplaySource[],
renderSkillDisplayBlocks: boolean,
): { parts: DisplayPart[]; placedKeys: Set<string> } { ): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g; const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = []; const parts: DisplayPart[] = [];
@@ -569,13 +636,15 @@ function splitSkillDisplayPlaceholders(
const attrs = parseDisplayAttrs(match[1] ?? ""); const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode); const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) { if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({ parts.push({
type: "display", type: "display",
key: group.key, key: group.key,
mode: group.mode, mode: group.mode,
sources: group.sources, sources: group.sources,
}); });
placedKeys.add(group.key);
} }
cursor = start + match[0].length; cursor = start + match[0].length;
} }
@@ -1,7 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Conversation, Conversation,
@@ -9,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -77,6 +78,23 @@ export function MessageList({
); );
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
@@ -35,6 +35,7 @@ const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
["summaryField", "摘要字段"], ["summaryField", "摘要字段"],
["contentField", "正文内容字段"], ["contentField", "正文内容字段"],
["sourceField", "来源字段"], ["sourceField", "来源字段"],
["authorField", "作者字段"],
["typeField", "类型字段"], ["typeField", "类型字段"],
["timeField", "时间字段"], ["timeField", "时间字段"],
["scoreField", "相关度字段"], ["scoreField", "相关度字段"],
@@ -99,7 +99,7 @@ function SourceDetailDialog({
}) { }) {
const content = source?.content || source?.snippet; const content = source?.content || source?.snippet;
const meta = source const meta = source
? [source.source, source.type, source.time].filter(Boolean) ? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
: []; : [];
return ( return (
@@ -66,12 +66,13 @@ export function SourceCard({
: null; : null;
const meta = [ const meta = [
source.source, source.source,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null, source.type && source.type !== "result" ? source.type : null,
source.time, source.time,
score ? `相关度 ${score}` : null, score ? `相关度 ${score}` : null,
].filter(Boolean); ].filter(Boolean);
const isCitation = source.mode === "citation"; const isCitation = source.mode === "citation";
const showReferenceNumber = referenceTarget || isCitation; const showReferenceNumber = isCitation;
return ( return (
<> <>
+20
View File
@@ -96,6 +96,26 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
return res.json() as Promise<AgentSelectorResponse>; return res.json() as Promise<AgentSelectorResponse>;
} }
export async function favoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "POST",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
}
}
export async function unfavoriteAgent(id: string): Promise<void> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
}
}
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> { export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch( const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`, `${getBackendBaseURL()}/api/agents/featured/order`,
+30
View File
@@ -9,6 +9,7 @@ import {
adminSyncAgentSkills, adminSyncAgentSkills,
createAgent, createAgent,
deleteAgent, deleteAgent,
favoriteAgent,
getAgent, getAgent,
getAgentSelector, getAgentSelector,
listAgents, listAgents,
@@ -17,6 +18,7 @@ import {
reorderFeaturedAgents, reorderFeaturedAgents,
setAgentFeatured, setAgentFeatured,
setAgentPrivate, setAgentPrivate,
unfavoriteAgent,
updateAgent, updateAgent,
} from "./api"; } from "./api";
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
@@ -112,6 +114,34 @@ export function useDeleteAgent() {
}); });
} }
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
* ``is_favorite`` across every cached agent listing + detail so the star
* reacts instantly before the round-trip completes. */
export function useToggleAgentFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
onMutate: ({ id, favorite }) => {
queryClient.setQueriesData<Agent[] | undefined>(
{ queryKey: ["agents"] },
(old) =>
Array.isArray(old)
? old.map((a) =>
a.id === id ? { ...a, is_favorite: favorite } : a,
)
: old,
);
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
old ? { ...old, is_favorite: favorite } : old,
);
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["agents"] });
},
});
}
export function useAgentSelector() { export function useAgentSelector() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"], queryKey: ["agents", "selector"],
+2
View File
@@ -16,6 +16,8 @@ export interface Agent {
skills?: string[] | null; skills?: string[] | null;
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
} }
export interface AgentSelectorResponse { export interface AgentSelectorResponse {
@@ -0,0 +1,105 @@
import { useSyncExternalStore } from "react";
/**
* Durable, browser-local record of when each chat question was asked, keyed by
* the message id. Captured at send time and reconciled to the server-assigned
* message id (see ``useThreadStream``), so the time survives reloads and does
* not depend on the backend round-tripping a timestamp on the message.
*/
const STORAGE_KEY = "deerflow.chat-ask-times";
type TimeMap = Record<string, string>;
function isBrowser(): boolean {
return typeof window !== "undefined";
}
let cache: TimeMap | null = null;
const EMPTY: TimeMap = {};
const listeners = new Set<() => void>();
let storageBound = false;
function readFromStorage(): TimeMap {
if (!isBrowser()) return EMPTY;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
} catch {
return {};
}
}
function getSnapshot(): TimeMap {
if (cache === null) cache = readFromStorage();
return cache;
}
function getServerSnapshot(): TimeMap {
return EMPTY;
}
function commit(next: TimeMap): void {
cache = next;
if (isBrowser()) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Keep the in-memory cache even if persistence fails.
}
}
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
if (isBrowser() && !storageBound) {
storageBound = true;
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY || event.key === null) {
cache = readFromStorage();
for (const l of listeners) l();
}
});
}
return () => {
listeners.delete(listener);
};
}
/** Record the ask time for a message id. First write wins (the true ask time). */
export function rememberAskTime(messageId: string, iso: string): void {
if (!messageId) return;
const current = getSnapshot();
if (current[messageId]) return;
commit({ ...current, [messageId]: iso });
}
export function getAskTime(messageId: string): string | undefined {
return getSnapshot()[messageId];
}
/**
* Record several message times at once (first write wins per id). Used to stamp
* a freshly loaded/streamed batch of messages in a single commit so a long
* conversation doesn't trigger one storage write + re-render per message.
*/
export function rememberAskTimes(entries: Record<string, string>): void {
const current = getSnapshot();
let changed = false;
const next: TimeMap = { ...current };
for (const [id, iso] of Object.entries(entries)) {
if (id && !next[id]) {
next[id] = iso;
changed = true;
}
}
if (changed) commit(next);
}
/** Reactive map of messageId → ISO ask time. */
export function useChatAskTimes(): TimeMap {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
@@ -0,0 +1,112 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/** A user-defined reusable prompt, bound to the account (stored server-side). */
export interface CustomPrompt {
id: string;
title: string;
content: string;
sort_order?: number;
created_at?: string | null;
updated_at?: string | null;
}
const QUERY_KEY = ["user-prompts"] as const;
const EMPTY_PROMPTS: CustomPrompt[] = [];
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
async function listUserPrompts(): Promise<CustomPrompt[]> {
const res = await apiFetch(baseUrl());
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
const data = (await res.json()) as { prompts: CustomPrompt[] };
return data.prompts;
}
async function createUserPrompt(input: {
title: string;
content: string;
}): Promise<CustomPrompt> {
const res = await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
}
return res.json() as Promise<CustomPrompt>;
}
async function updateUserPrompt(
id: string,
input: { title?: string; content?: string },
): Promise<CustomPrompt> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
}
return res.json() as Promise<CustomPrompt>;
}
async function deleteUserPrompt(id: string): Promise<void> {
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
}
}
/** Reactive list of the current user's saved prompts (server-backed). */
export function useCustomPrompts(): CustomPrompt[] {
const { data } = useQuery({
queryKey: QUERY_KEY,
queryFn: listUserPrompts,
});
return data ?? EMPTY_PROMPTS;
}
export function useCreateCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUserPrompt,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
export function useUpdateCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
input,
}: {
id: string;
input: { title?: string; content?: string };
}) => updateUserPrompt(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
export function useDeleteCustomPrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteUserPrompt,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
}
@@ -16,6 +16,7 @@ export type SearchResultItem = {
snippet?: string; snippet?: string;
content?: string; content?: string;
source?: string; source?: string;
author?: string;
published_date?: string; published_date?: string;
publishedDate?: string; publishedDate?: string;
date?: string; date?: string;
@@ -111,6 +112,15 @@ const SOURCE_ALIASES = [
"kb", "kb",
] as const; ] as const;
const AUTHOR_ALIASES = [
"author",
"authors",
"creator",
"writer",
"byline",
"owner",
] as const;
const DATE_ALIASES = [ const DATE_ALIASES = [
"published_date", "published_date",
"publishedDate", "publishedDate",
@@ -131,6 +141,8 @@ const CONTENT_KEY_PATTERN =
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i; const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
const SOURCE_KEY_PATTERN = const SOURCE_KEY_PATTERN =
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i; /(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
const AUTHOR_KEY_PATTERN =
/(?:^|_)(?:author|authors|creator|writer|byline|owner)(?:$|_)/i;
const DATE_KEY_PATTERN = const DATE_KEY_PATTERN =
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i; /(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
@@ -301,6 +313,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
excludeUrl: true, excludeUrl: true,
maxLength: 120, maxLength: 120,
}); });
const author =
pickFromLayers(layers, AUTHOR_ALIASES) ??
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
excludeUrl: true,
maxLength: 120,
});
const date = const date =
pickFromLayers(layers, DATE_ALIASES) ?? pickFromLayers(layers, DATE_ALIASES) ??
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 }); pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
@@ -310,7 +328,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]); const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
const reserved = new Set<string>(); const reserved = new Set<string>();
for (const value of [title, content, url, source, date]) { for (const value of [title, content, url, source, author, date]) {
if (value) { if (value) {
reserved.add(value); reserved.add(value);
} }
@@ -341,6 +359,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
snippet: content, snippet: content,
content, content,
source, source,
author,
published_date: date, published_date: date,
publishedDate: date, publishedDate: date,
date, date,
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
schedule: string; schedule: string;
timezone?: string | null; timezone?: string | null;
execution_agent_name?: string | null; execution_agent_name?: string | null;
require_markdown?: boolean;
}) { }) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), { await apiFetch(baseUrl(), {
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
timezone: string; timezone: string;
enabled: boolean; enabled: boolean;
execution_agent_name: string | null; execution_agent_name: string | null;
require_markdown: boolean;
}>, }>,
) { ) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
timezone: string; timezone: string;
enabled: boolean; enabled: boolean;
execution_agent_name: string | null; execution_agent_name: string | null;
require_markdown: boolean;
}>, }>,
) { ) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
@@ -20,6 +20,12 @@ export type ScheduledTask = {
cron_expr: string; cron_expr: string;
timezone: string; timezone: string;
enabled: boolean; enabled: boolean;
/**
* Free-form execution context persisted with the task. ``require_markdown``
* lives here (rather than a dedicated column) so the feature needs no schema
* migration.
*/
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
next_run_at?: string | null; next_run_at?: string | null;
last_run_at?: string | null; last_run_at?: string | null;
last_status?: string | null; last_status?: string | null;
@@ -10,6 +10,7 @@ export interface DisplaySourceItem {
type: string; type: string;
snippet: string; snippet: string;
source?: string; source?: string;
author?: string;
time?: string; time?: string;
url?: string; url?: string;
content?: string; content?: string;
@@ -25,6 +26,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
summaryField: null, summaryField: null,
contentField: null, contentField: null,
sourceField: null, sourceField: null,
authorField: null,
typeField: null, typeField: null,
timeField: null, timeField: null,
scoreField: null, scoreField: null,
@@ -43,6 +45,7 @@ const candidateFields: Record<string, string[]> = {
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"], summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
contentField: ["content", "page_content", "text", "content_preview", "body"], contentField: ["content", "page_content", "text", "content_preview", "body"],
sourceField: ["source", "source_name", "site", "from", "origin"], sourceField: ["source", "source_name", "site", "from", "origin"],
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
typeField: ["type", "category", "source_category", "source1"], typeField: ["type", "category", "source_category", "source1"],
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"], timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
scoreField: ["relevance_score", "rerank_score", "score", "similarity"], scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
@@ -107,6 +110,7 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
inferred.summaryField = inferred.summaryField || "content_preview"; inferred.summaryField = inferred.summaryField || "content_preview";
inferred.contentField = inferred.contentField || "content_preview"; inferred.contentField = inferred.contentField || "content_preview";
inferred.sourceField = inferred.sourceField || "source"; inferred.sourceField = inferred.sourceField || "source";
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
inferred.typeField = inferred.typeField || "source_category"; inferred.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time"; inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score"; inferred.scoreField = inferred.scoreField || "relevance_score";
@@ -127,6 +131,7 @@ function buildDefaultTableColumns(
config.titleField, config.titleField,
config.typeField, config.typeField,
config.sourceField, config.sourceField,
config.authorField,
config.timeField, config.timeField,
config.scoreField, config.scoreField,
].filter((field): field is string => !!field); ].filter((field): field is string => !!field);
@@ -141,6 +146,16 @@ function buildDefaultTableColumns(
function stringify(value: unknown, fallback = ""): string { function stringify(value: unknown, fallback = ""): string {
if (value === null || value === undefined) return fallback; if (value === null || value === undefined) return fallback;
if (Array.isArray(value)) {
const parts = value
.map((item) =>
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
? String(item).trim()
: "",
)
.filter(Boolean);
return parts.length > 0 ? parts.join("、") : fallback;
}
return String(value); return String(value);
} }
@@ -174,6 +189,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
snippet: config.summaryField ? stringify(item[config.summaryField]) : "", snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined, content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined, source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
author: config.authorField ? stringify(item[config.authorField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined, time: config.timeField ? stringify(item[config.timeField]) : undefined,
score: score:
config.scoreField && typeof item[config.scoreField] === "number" config.scoreField && typeof item[config.scoreField] === "number"
@@ -499,6 +499,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
snippet: item.snippet ?? item.content ?? "", snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet, content: item.content ?? item.snippet,
source: item.source, source: item.source,
author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date, time: item.published_date ?? item.publishedDate ?? item.date,
url: item.url, url: item.url,
raw: item as unknown as Record<string, unknown>, raw: item as unknown as Record<string, unknown>,
+1
View File
@@ -20,6 +20,7 @@ export interface SkillDisplayConfig {
summaryField?: string | null; summaryField?: string | null;
contentField?: string | null; contentField?: string | null;
sourceField?: string | null; sourceField?: string | null;
authorField?: string | null;
typeField?: string | null; typeField?: string | null;
timeField?: string | null; timeField?: string | null;
scoreField?: string | null; scoreField?: string | null;
+48 -1
View File
@@ -11,6 +11,7 @@ import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { getStoredAuth } from "../auth"; import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
@@ -378,6 +379,13 @@ export function useThreadStream({
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]); const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const sendInFlightRef = useRef(false); const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
// already existed at send time, used to spot the newly-arrived one.
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
null,
);
// Track message count before sending so we know when server has responded // Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length); const prevMsgCountRef = useRef(thread.messages.length);
@@ -429,6 +437,26 @@ export function useThreadStream({
} }
}, [thread.messages.length, optimisticMessages.length]); }, [thread.messages.length, optimisticMessages.length]);
// Bind a just-sent question's ask time to the server-assigned human message
// id once it appears. Persists to localStorage so the time shows on every
// question and survives reloads, independent of the backend echoing it back.
useEffect(() => {
const pending = pendingAskTimeRef.current;
if (!pending) return;
for (const message of thread.messages) {
if (
message.type === "human" &&
message.id &&
!message.id.startsWith("opt-") &&
!pending.known.has(message.id)
) {
rememberAskTime(message.id, pending.ts);
pendingAskTimeRef.current = null;
break;
}
}
}, [thread.messages]);
const sendMessage = useCallback( const sendMessage = useCallback(
async ( async (
threadId: string, threadId: string,
@@ -442,6 +470,20 @@ export function useThreadStream({
sendInFlightRef.current = true; sendInFlightRef.current = true;
const text = message.text.trim(); const text = message.text.trim();
// Wall-clock time the question was asked. Stored on the human message so
// the chat can show "几点提问" and have it survive a reload (it round-trips
// through the checkpoint inside additional_kwargs, same as `files`).
const clientTs = new Date().toISOString();
// Remember which human messages already exist so the reconciliation
// effect can bind ``clientTs`` to the new one once the server returns it.
pendingAskTimeRef.current = {
ts: clientTs,
known: new Set(
thread.messages
.filter((m) => m.type === "human" && m.id)
.map((m) => m.id as string),
),
};
// Capture current count before showing optimistic messages // Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length; prevMsgCountRef.current = thread.messages.length;
@@ -458,6 +500,7 @@ export function useThreadStream({
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true; const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
const optimisticAdditionalKwargs = { const optimisticAdditionalKwargs = {
...options?.additionalKwargs, ...options?.additionalKwargs,
client_ts: clientTs,
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}), ...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
}; };
@@ -535,7 +578,10 @@ export function useThreadStream({
return [ return [
{ {
...humanMessage, ...humanMessage,
additional_kwargs: { files: uploadedFiles }, additional_kwargs: {
...humanMessage.additional_kwargs,
files: uploadedFiles,
},
}, },
...messages.slice(1), ...messages.slice(1),
]; ];
@@ -585,6 +631,7 @@ export function useThreadStream({
], ],
additional_kwargs: { additional_kwargs: {
...options?.additionalKwargs, ...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0 ...(filesForSubmit.length > 0
? { files: filesForSubmit } ? { files: filesForSubmit }
: {}), : {}),
@@ -84,6 +84,7 @@ const emptyForm = {
timezone: "Asia/Shanghai", timezone: "Asia/Shanghai",
prompt: "", prompt: "",
execution_agent_name: "", execution_agent_name: "",
require_markdown: false,
}; };
export default function ScheduledTasksPage() { export default function ScheduledTasksPage() {
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
timezone: task.timezone, timezone: task.timezone,
prompt: task.prompt, prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "", execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown,
}); });
// Prefer the canonical cron_expr over the user-typed schedule_text: the // Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read, // latter may be natural language ("每天9点") that parseCron can't read,
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone || "Asia/Shanghai", timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt, prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
}); });
setSelectedTaskId(task.task_id); setSelectedTaskId(task.task_id);
toast.success("任务已创建"); toast.success("任务已创建");
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone, timezone: form.timezone,
prompt: form.prompt, prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
}, },
}); });
toast.success("任务已更新"); toast.success("任务已更新");
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
} }
placeholder="任务 Prompt" placeholder="任务 Prompt"
/> />
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={closeDialog}> <Button variant="outline" onClick={closeDialog}>
@@ -53,6 +53,7 @@ from app.gateway.routers import (
thread_shares, thread_shares,
threads, threads,
uploads, uploads,
user_prompts,
writing, writing,
) )
from deerflow.config import app_config as deerflow_app_config from deerflow.config import app_config as deerflow_app_config
@@ -824,6 +825,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Recommended Questions API is mounted at /api/recommended-questions # Recommended Questions API is mounted at /api/recommended-questions
app.include_router(recommended_questions.router) app.include_router(recommended_questions.router)
app.include_router(user_prompts.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router) app.include_router(suggestions.router)
@@ -148,6 +148,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.memory_config_store = make_user_memory_config_store(sf) app.state.memory_config_store = make_user_memory_config_store(sf)
from deerflow.persistence.user_prompts import make_user_prompt_store
app.state.user_prompt_store = make_user_prompt_store(sf)
# Run event store (has its own factory with config-driven backend selection) # Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None) run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config) app.state.run_event_store = make_run_event_store(run_events_config)
@@ -59,6 +59,11 @@ class AgentResponse(BaseModel):
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.", description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
) )
is_favorite: bool = Field(
default=False,
description="Whether the requesting user has favorited (收藏) this agent.",
)
class AgentsListResponse(BaseModel): class AgentsListResponse(BaseModel):
"""Response model for listing all visible agents.""" """Response model for listing all visible agents."""
@@ -440,8 +445,10 @@ async def list_agents(
] ]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
favorite_ids = await store.list_favorite_ids(user_id)
for resp in responses: for resp in responses:
resp.tags = assignments.get(resp.id, []) resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
if search: if search:
needle = search.strip().lower() needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()] responses = [r for r in responses if needle in r.name.lower()]
@@ -520,7 +527,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
# Detail view legitimately reads SOUL.md + config.yaml — hop to a # Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent # worker thread so the asyncio loop stays responsive under concurrent
# detail fetches. # detail fetches.
return await _record_to_response_async(record, include_soul=True, include_extras=True) response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids
return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -747,6 +757,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id) await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass pass
try:
await store.delete_favorites_for_agent(agent_id)
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
pass
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir) logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
except HTTPException: except HTTPException:
raise raise
@@ -755,6 +769,53 @@ async def delete_agent(agent_id: str, request: Request) -> None:
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
@router.post(
"/agents/{agent_id}/favorite",
status_code=204,
summary="Favorite Agent",
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
)
async def favorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
await store.add_favorite(user_id, agent_id)
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
@router.delete(
"/agents/{agent_id}/favorite",
status_code=204,
summary="Unfavorite Agent",
description="Remove an agent from the current user's favorites. No-op when not favorited.",
)
async def unfavorite_agent(agent_id: str, request: Request) -> None:
_require_agents_api_enabled()
agent_id = _validate_storage_id(agent_id)
try:
store = get_agent_store(request)
user_id = _current_user_id(request)
await store.remove_favorite(user_id, agent_id)
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
@router.put( @router.put(
"/agents/{agent_id}/featured", "/agents/{agent_id}/featured",
response_model=AgentResponse, response_model=AgentResponse,
@@ -37,6 +37,10 @@ class ScheduledTaskCreateRequest(BaseModel):
timezone: str | None = None timezone: str | None = None
execution_agent_name: str | None = None execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None execution_context: dict[str, Any] | None = None
require_markdown: bool = Field(
default=False,
description="Require the result to be Markdown and exceed the minimum length, else mark the run failed.",
)
class ScheduledTaskUpdateRequest(BaseModel): class ScheduledTaskUpdateRequest(BaseModel):
@@ -48,6 +52,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
enabled: bool | None = None enabled: bool | None = None
execution_agent_name: str | None = None execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None execution_context: dict[str, Any] | None = None
require_markdown: bool | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel): class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
@@ -555,6 +560,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
timezone=body.timezone, timezone=body.timezone,
execution_agent_name=body.execution_agent_name, execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context, execution_context=body.execution_context,
require_markdown=body.require_markdown,
) )
except DuplicateTaskNameError: except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称") raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
@@ -35,6 +35,7 @@ from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import ( from deerflow.runtime.references import (
adapt_reference_batches_with_displays, adapt_reference_batches_with_displays,
build_reference_batches as build_runtime_reference_batches, build_reference_batches as build_runtime_reference_batches,
build_reference_debug_payload,
build_reference_debug_summary, build_reference_debug_summary,
) )
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
@@ -231,6 +232,7 @@ class ReferenceSourceResponse(BaseModel):
type: str = "result" type: str = "result"
snippet: str = "" snippet: str = ""
source: str | None = None source: str | None = None
author: str | None = None
time: str | None = None time: str | None = None
url: str | None = None url: str | None = None
content: str | None = None content: str | None = None
@@ -843,7 +845,8 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
_rag_debug_file_path(), _rag_debug_file_path(),
) )
return ReferenceBatchesResponse(batches=[]) return ReferenceBatchesResponse(batches=[])
raw_batches = build_runtime_reference_batches(messages) batches_before_display_adapt = build_runtime_reference_batches(messages)
raw_batches = batches_before_display_adapt
persisted_displays = await _load_persisted_skill_displays() persisted_displays = await _load_persisted_skill_displays()
if persisted_displays: if persisted_displays:
raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays) raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays)
@@ -866,6 +869,25 @@ async def get_thread_reference_batches(thread_id: str, request: Request) -> Refe
json.dumps(debug_summary["turns_detail"], ensure_ascii=False), json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
_rag_debug_file_path(), _rag_debug_file_path(),
) )
_log_rag_debug(
"backend reference-batches-payload thread=%s %s",
sanitize_log_param(thread_id),
json.dumps(
{
"display_adapter_names": sorted(persisted_displays.keys()),
"before_display_adapt": build_reference_debug_payload(
messages=messages,
batches=batches_before_display_adapt,
),
"after_display_adapt": build_reference_debug_payload(
messages=messages,
batches=raw_batches,
),
},
ensure_ascii=False,
default=str,
),
)
return ReferenceBatchesResponse( return ReferenceBatchesResponse(
batches=[ batches=[
ReferenceBatchResponse( ReferenceBatchResponse(
@@ -0,0 +1,108 @@
"""CRUD API for a user's personal custom prompts (常用提示词).
Prompts are bound to the requesting user (not stored in the browser), so they
follow the account across devices/sessions.
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
class UserPromptResponse(BaseModel):
id: str
title: str = ""
content: str
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class UserPromptsListResponse(BaseModel):
prompts: list[UserPromptResponse]
class UserPromptCreateRequest(BaseModel):
title: str = Field(default="", max_length=255)
content: str = Field(..., min_length=1)
sort_order: int = Field(default=0)
class UserPromptUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=255)
content: str | None = Field(default=None, min_length=1)
sort_order: int | None = None
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "user_prompt_store", None)
if store is None:
raise HTTPException(status_code=503, detail="User prompt store not available")
return store
@router.get("", response_model=UserPromptsListResponse)
async def list_user_prompts(request: Request) -> UserPromptsListResponse:
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_for_user(user_id)
return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
@router.post("", response_model=UserPromptResponse, status_code=201)
async def create_user_prompt(request: Request, body: UserPromptCreateRequest) -> UserPromptResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create(
user_id,
{
"id": uuid4().hex,
"title": (body.title or "").strip(),
"content": body.content,
"sort_order": body.sort_order,
},
)
return UserPromptResponse(**row)
@router.put("/{prompt_id}", response_model=UserPromptResponse)
async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptUpdateRequest) -> UserPromptResponse:
store = _get_store(request)
user_id = _current_user_id(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.content is not None:
data["content"] = body.content
if body.sort_order is not None:
data["sort_order"] = body.sort_order
row = await store.update(prompt_id, user_id, data)
if row is None:
raise HTTPException(status_code=404, detail="Prompt not found")
return UserPromptResponse(**row)
@router.delete("/{prompt_id}", status_code=204)
async def delete_user_prompt(request: Request, prompt_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete(prompt_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Prompt not found")
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
@@ -13,7 +14,11 @@ from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import SystemMessage from langchain_core.messages import SystemMessage
from deerflow.runtime.references import build_latest_reference_prompt, build_reference_debug_summary from deerflow.runtime.references import (
build_latest_reference_prompt,
build_reference_debug_payload,
build_reference_debug_summary,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug" _RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
@@ -84,6 +89,18 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
summary["kept_items"], summary["kept_items"],
len((batch or {}).get("sources") or []), len((batch or {}).get("sources") or []),
) )
_log_rag_debug(
"middleware reference-prompt-payload %s",
json.dumps(
build_reference_debug_payload(
messages=messages,
batch=batch,
prompt=prompt,
),
ensure_ascii=False,
default=str,
),
)
patched_messages = [ patched_messages = [
*messages, *messages,
SystemMessage(content=prompt), SystemMessage(content=prompt),
@@ -75,6 +75,7 @@ class SkillDisplayConfig(BaseModel):
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField") summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField") content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField") source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField") type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField") time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField") score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
@@ -4,10 +4,11 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.memory import MemoryAgentStore from deerflow.persistence.agents.memory import MemoryAgentStore
from deerflow.persistence.agents.model import AgentRow from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.agents.sql import AgentRepository from deerflow.persistence.agents.sql import AgentRepository
__all__ = [ __all__ = [
"AgentFavoriteRow",
"AgentRepository", "AgentRepository",
"AgentRow", "AgentRow",
"AgentStore", "AgentStore",
@@ -117,3 +117,25 @@ class AgentStore(ABC):
``False`` if the agent does not exist. ``False`` if the agent does not exist.
""" """
raise NotImplementedError raise NotImplementedError
# --- Favorites ("收藏") ---
@abstractmethod
async def add_favorite(self, user_id: str, agent_id: str) -> None:
"""Bookmark ``agent_id`` for ``user_id``. Idempotent."""
raise NotImplementedError
@abstractmethod
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
"""Remove a bookmark. Returns ``False`` when none existed."""
raise NotImplementedError
@abstractmethod
async def list_favorite_ids(self, user_id: str) -> set[str]:
"""Return the set of agent ids ``user_id`` has favorited."""
raise NotImplementedError
@abstractmethod
async def delete_favorites_for_agent(self, agent_id: str) -> None:
"""Drop every user's favorite of ``agent_id`` (called on agent delete)."""
raise NotImplementedError
@@ -21,6 +21,7 @@ def _copy(data: dict[str, Any]) -> dict[str, Any]:
class MemoryAgentStore(AgentStore): class MemoryAgentStore(AgentStore):
def __init__(self) -> None: def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {} self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
@staticmethod @staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool: def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -164,3 +165,20 @@ class MemoryAgentStore(AgentStore):
return False return False
self._agents.pop(agent_id, None) self._agents.pop(agent_id, None)
return True return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
self._favorites.add((user_id, agent_id))
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
if (user_id, agent_id) in self._favorites:
self._favorites.discard((user_id, agent_id))
return True
return False
async def list_favorite_ids(self, user_id: str) -> set[str]:
return {agent_id for (uid, agent_id) in self._favorites if uid == user_id}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
self._favorites = {(uid, aid) for (uid, aid) in self._favorites if aid != agent_id}
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import BigInteger, Boolean, String from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
@@ -35,3 +35,20 @@ class AgentRow(Base):
featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True) featured_order: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class AgentFavoriteRow(Base):
"""A user's bookmark of a (typically public-square) agent.
Lets a user collect agents from the 广场 (square) into their "我的" tab
without owning them. One row per (user, agent); the unique constraint makes
favoriting idempotent.
"""
__tablename__ = "agent_favorites"
__table_args__ = (UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -4,12 +4,14 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from uuid import uuid4
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, delete, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.agents.model import AgentRow from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
def _row_to_dict(row: AgentRow) -> dict[str, Any]: def _row_to_dict(row: AgentRow) -> dict[str, Any]:
@@ -230,3 +232,47 @@ class AgentRepository(AgentStore):
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()
return True return True
# --- Favorites ("收藏") ---
async def add_favorite(self, user_id: str, agent_id: str) -> None:
async with self._sf() as session:
existing = (
await session.execute(
select(AgentFavoriteRow.id)
.where(AgentFavoriteRow.user_id == user_id, AgentFavoriteRow.agent_id == agent_id)
.limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
session.add(AgentFavoriteRow(id=uuid4().hex, user_id=user_id, agent_id=agent_id))
try:
await session.commit()
except IntegrityError:
# Lost a race against a concurrent favorite of the same agent —
# the unique constraint already guarantees the desired state.
await session.rollback()
async def remove_favorite(self, user_id: str, agent_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(AgentFavoriteRow).where(
AgentFavoriteRow.user_id == user_id,
AgentFavoriteRow.agent_id == agent_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_favorite_ids(self, user_id: str) -> set[str]:
async with self._sf() as session:
result = await session.execute(
select(AgentFavoriteRow.agent_id).where(AgentFavoriteRow.user_id == user_id)
)
return {row[0] for row in result.all()}
async def delete_favorites_for_agent(self, agent_id: str) -> None:
async with self._sf() as session:
await session.execute(delete(AgentFavoriteRow).where(AgentFavoriteRow.agent_id == agent_id))
await session.commit()
@@ -0,0 +1,45 @@
"""Add ``agent_favorites`` for the agent collection (收藏) feature.
Lets a user bookmark agents from the public square (广场) so they surface in
their "我的" tab without owning them. One row per (user, agent).
Revision ID: 20260528_01
Revises: 20260526_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_01"
down_revision = "20260526_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("agent_favorites"):
op.create_table(
"agent_favorites",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("agent_id", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "agent_id", name="uq_agent_favorites_user_agent"),
)
op.create_index("ix_agent_favorites_user_id", "agent_favorites", ["user_id"])
op.create_index("ix_agent_favorites_agent_id", "agent_favorites", ["agent_id"])
def downgrade() -> None:
if _has_table("agent_favorites"):
op.drop_table("agent_favorites")
@@ -0,0 +1,45 @@
"""Add ``user_custom_prompts`` for per-user saved prompts (常用提示词).
Bound to the user (not the browser) so prompts follow the account.
Revision ID: 20260528_02
Revises: 20260528_01
Create Date: 2026-05-28
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260528_02"
down_revision = "20260528_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_table("user_custom_prompts"):
op.create_table(
"user_custom_prompts",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("title", sa.String(255), nullable=False, server_default=""),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_user_custom_prompts_user_id", "user_custom_prompts", ["user_id"])
def downgrade() -> None:
if _has_table("user_custom_prompts"):
op.drop_table("user_custom_prompts")
@@ -15,7 +15,7 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory. there is no matching entity directory.
""" """
from deerflow.persistence.agents.model import AgentRow from deerflow.persistence.agents.model import AgentFavoriteRow, AgentRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -36,9 +36,11 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [ __all__ = [
"FeedbackRow", "FeedbackRow",
"AgentFavoriteRow",
"AgentRow", "AgentRow",
"LlmCallMetricRow", "LlmCallMetricRow",
"NotificationRow", "NotificationRow",
@@ -57,6 +59,7 @@ __all__ = [
"ThreadMetaRow", "ThreadMetaRow",
"ThreadShareRow", "ThreadShareRow",
"ToolCallMetricRow", "ToolCallMetricRow",
"UserCustomPromptRow",
"UserMemoryConfigRow", "UserMemoryConfigRow",
"UserRow", "UserRow",
] ]
@@ -0,0 +1,18 @@
"""Per-user custom prompt persistence."""
from __future__ import annotations
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
from deerflow.persistence.user_prompts.sql import UserCustomPromptRepository
__all__ = [
"UserCustomPromptRepository",
"UserCustomPromptRow",
"make_user_prompt_store",
]
def make_user_prompt_store(session_factory):
if session_factory is None:
return None
return UserCustomPromptRepository(session_factory)
@@ -0,0 +1,23 @@
"""ORM model for per-user custom prompts (常用提示词)."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class UserCustomPromptRow(Base):
__tablename__ = "user_custom_prompts"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="")
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,74 @@
"""SQLAlchemy-backed per-user custom prompt repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
def _row_to_dict(row: UserCustomPromptRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class UserCustomPromptRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_for_user(self, user_id: str) -> list[dict[str, Any]]:
stmt = (
select(UserCustomPromptRow)
.where(UserCustomPromptRow.user_id == user_id)
.order_by(UserCustomPromptRow.sort_order.asc(), UserCustomPromptRow.created_at.asc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def create(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = UserCustomPromptRow(
id=data["id"],
user_id=user_id,
title=data.get("title") or "",
content=data["content"],
sort_order=data.get("sort_order", 0),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, prompt_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return None
for key in ("title", "content", "sort_order"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, prompt_id: str, user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(UserCustomPromptRow, prompt_id)
if row is None or row.user_id != user_id:
return False
await session.delete(row)
await session.commit()
return True
@@ -60,7 +60,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
title = source.get("title") or f"参考文献 {source.get('index')}" title = source.get("title") or f"参考文献 {source.get('index')}"
meta = " · ".join( meta = " · ".join(
str(item) str(item)
for item in (source.get("source"), source.get("time")) for item in (source.get("source"), source.get("author"), source.get("time"))
if item if item
) )
content = str(source.get("content") or source.get("snippet") or "").strip() content = str(source.get("content") or source.get("snippet") or "").strip()
@@ -81,6 +81,7 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A
"3. 只能引用上方存在的编号,不要编造编号。", "3. 只能引用上方存在的编号,不要编造编号。",
"4. 不要使用工具原始编号,也不要重新排序编号。", "4. 不要使用工具原始编号,也不要重新排序编号。",
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。", "5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
] ]
) )
return "\n".join(lines).strip(), batch return "\n".join(lines).strip(), batch
@@ -157,6 +158,26 @@ def build_reference_debug_summary(messages: list[Any], batches: list[dict[str, A
} }
def build_reference_debug_payload(
*,
messages: list[Any] | None = None,
batch: dict[str, Any] | None = None,
batches: list[dict[str, Any]] | None = None,
prompt: str | None = None,
) -> dict[str, Any]:
"""Return a JSON-safe payload for comparing model prompts and UI batches."""
payload: dict[str, Any] = {}
if messages is not None:
payload["message_trace"] = [_message_debug_item(index, message) for index, message in enumerate(messages)]
if prompt is not None:
payload["model_reference_prompt"] = prompt
if batch is not None:
payload["batch"] = _batch_debug_item(batch)
if batches is not None:
payload["batches"] = [_batch_debug_item(item) for item in batches]
return payload
def adapt_reference_batches_with_displays( def adapt_reference_batches_with_displays(
batches: list[dict[str, Any]], batches: list[dict[str, Any]],
displays: dict[str, SkillDisplayConfig | dict[str, Any]], displays: dict[str, SkillDisplayConfig | dict[str, Any]],
@@ -235,6 +256,65 @@ def adapt_reference_batches_with_displays(
return adapted_batches return adapted_batches
def _message_debug_item(index: int, message: Any) -> dict[str, Any]:
content = _message_content(message)
tool_calls = _extract_tool_calls(message)
return {
"position": index,
"type": _message_type(message),
"id": _message_id(message),
"name": _message_name(message) if _message_type(message) == "tool" else None,
"tool_call_id": _message_tool_call_id(message) if _message_type(message) == "tool" else None,
"tool_calls": [
{
"id": call.get("id"),
"name": call.get("name"),
"args": call.get("args"),
}
for call in tool_calls
],
"content_length": len(content) if isinstance(content, str) else None,
}
def _batch_debug_item(batch: dict[str, Any]) -> dict[str, Any]:
sources = [source for source in batch.get("sources", []) if isinstance(source, dict)]
return {
"id": batch.get("id"),
"assistant_message_id": batch.get("assistant_message_id"),
"source_count": len(sources),
"sources": [_source_debug_item(source) for source in sources],
}
def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
raw = source.get("raw")
raw_record = raw if isinstance(raw, dict) else {}
return {
"index": source.get("index"),
"id": source.get("id"),
"skillName": source.get("skillName"),
"mode": source.get("mode"),
"title": source.get("title"),
"source": source.get("source"),
"author": source.get("author"),
"time": source.get("time"),
"url": source.get("url"),
"snippet": source.get("snippet"),
"content": source.get("content"),
"raw_id_candidates": {
"recUuid": raw_record.get("recUuid"),
"rec_uuid": raw_record.get("rec_uuid"),
"uuid": raw_record.get("uuid"),
"docId": raw_record.get("docId"),
"doc_id": raw_record.get("doc_id"),
"id": raw_record.get("id"),
"url": raw_record.get("url"),
},
"raw": raw,
}
def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]: def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = [] sources: list[dict[str, Any]] = []
pending_tool_contexts: list[dict[str, Any]] = [] pending_tool_contexts: list[dict[str, Any]] = []
@@ -536,6 +616,10 @@ def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
value = layer.get(key) value = layer.get(key)
if isinstance(value, (str, int, float)) and str(value).strip(): if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip() return str(value).strip()
if isinstance(value, list):
text = _stringify(value, "")
if text:
return text
return None return None
@@ -604,6 +688,7 @@ def _normalize_with_display_config(
snippet = _stringify(_get_by_path(raw, display.summary_field), "") snippet = _stringify(_get_by_path(raw, display.summary_field), "")
content = _stringify(_get_by_path(raw, display.content_field), snippet) content = _stringify(_get_by_path(raw, display.content_field), snippet)
source = _stringify(_get_by_path(raw, display.source_field), "") or None source = _stringify(_get_by_path(raw, display.source_field), "") or None
author = _stringify(_get_by_path(raw, display.author_field), "") or None
item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result" item_type = _stringify(_get_by_path(raw, display.type_field), "result") or "result"
time = _stringify(_get_by_path(raw, display.time_field), "") or None time = _stringify(_get_by_path(raw, display.time_field), "") or None
item_id = _stringify(_get_by_path(raw, display.id_field), "") or None item_id = _stringify(_get_by_path(raw, display.id_field), "") or None
@@ -620,6 +705,7 @@ def _normalize_with_display_config(
"snippet": snippet, "snippet": snippet,
"content": content, "content": content,
"source": source, "source": source,
"author": author,
"time": time, "time": time,
"url": url, "url": url,
"score": score, "score": score,
@@ -667,6 +753,13 @@ def _stringify(value: Any, fallback: str = "") -> str:
if isinstance(value, (str, int, float)): if isinstance(value, (str, int, float)):
text = str(value).strip() text = str(value).strip()
return text if text else fallback return text if text else fallback
if isinstance(value, list):
parts = [
str(item).strip()
for item in value
if isinstance(item, (str, int, float)) and str(item).strip()
]
return "、".join(parts) if parts else fallback
return fallback return fallback
@@ -705,6 +798,7 @@ def _normalize_reference_item(
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body")) content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url")) url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
source = _pick(layers, ("source", "source1", "site", "provider", "from")) source = _pick(layers, ("source", "source1", "site", "provider", "from"))
author = _pick(layers, ("author", "authors", "creator", "writer", "byline", "publisher", "owner"))
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at")) time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId")) item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity")) score_raw = _pick(layers, ("score", "relevance", "similarity"))
@@ -729,6 +823,7 @@ def _normalize_reference_item(
"snippet": content, "snippet": content,
"content": content, "content": content,
"source": source, "source": source,
"author": author,
"time": time, "time": time,
"url": url, "url": url,
"score": score, "score": score,
@@ -1,4 +1,10 @@
from .registry import get_scheduled_task_service, set_scheduled_task_service from .registry import get_scheduled_task_service, set_scheduled_task_service
from .service import DuplicateTaskNameError, ScheduledTaskService from .service import DuplicateTaskNameError, MarkdownRequirementError, ScheduledTaskService
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"] __all__ = [
"DuplicateTaskNameError",
"MarkdownRequirementError",
"ScheduledTaskService",
"get_scheduled_task_service",
"set_scheduled_task_service",
]
@@ -36,6 +36,27 @@ _RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed". # Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3 _MAX_RUN_RETRIES = 3
# Minimum length (in characters) the generated Markdown file's content must
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
_MARKDOWN_INSTRUCTION = (
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
)
class MarkdownRequirementError(Exception):
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
The run is marked failed (no retry) so the user can see the output did not
meet the requirement (a Markdown file longer than the minimum length).
"""
class DuplicateTaskNameError(Exception): class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name. """A user already owns a scheduled task with the requested name.
@@ -166,6 +187,42 @@ def _looks_like_markdown(content: str) -> bool:
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns) return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
def _task_requires_markdown(task: dict[str, Any]) -> bool:
"""Whether a task has the ``require_markdown`` flag set.
The flag lives inside ``execution_context`` (no dedicated column) so the
feature works without a schema migration on existing databases.
"""
ctx = task.get("execution_context")
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
For tasks with ``require_markdown`` enabled the run only succeeds if at least
one generated file is Markdown (``.md``/``.markdown``) and its content is
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
archived path; binary/undecodable files never qualify.
"""
for item in result_files or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if not name.lower().endswith((".md", ".markdown")):
continue
path = item.get("path")
if not isinstance(path, str) or not path:
continue
try:
text = Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
return True
return False
def _build_result( def _build_result(
task: dict[str, Any], task: dict[str, Any],
*, *,
@@ -451,6 +508,7 @@ class ScheduledTaskService:
source_agent_name: str | None = None, source_agent_name: str | None = None,
execution_agent_name: str | None = None, execution_agent_name: str | None = None,
execution_context: dict[str, Any] | None = None, execution_context: dict[str, Any] | None = None,
require_markdown: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
tz = _normalize_timezone(timezone) tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule) cron_expr = _schedule_to_cron(schedule)
@@ -461,6 +519,7 @@ class ScheduledTaskService:
context = dict(execution_context or {}) context = dict(execution_context or {})
if execution_agent_name: if execution_agent_name:
context["agent_name"] = execution_agent_name context["agent_name"] = execution_agent_name
context["require_markdown"] = bool(require_markdown)
normalized_description = description.strip() if isinstance(description, str) else None normalized_description = description.strip() if isinstance(description, str) else None
task = await self.store.create_task( task = await self.store.create_task(
{ {
@@ -519,6 +578,18 @@ class ScheduledTaskService:
update_data["next_run_at"] = _next_run_at(cron_expr, tz) update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None: if data.get("execution_context") is not None:
update_data["execution_context"] = data["execution_context"] update_data["execution_context"] = data["execution_context"]
# ``require_markdown`` is persisted inside execution_context (no dedicated
# column), so merge it into the context being written — preferring a
# context supplied in the same request, else the task's existing one.
if data.get("require_markdown") is not None:
base_ctx = (
update_data["execution_context"]
if "execution_context" in update_data
else (existing or {}).get("execution_context")
)
ctx = dict(base_ctx or {})
ctx["require_markdown"] = bool(data["require_markdown"])
update_data["execution_context"] = ctx
# Re-enabling a task must also schedule it forward from now, otherwise a # Re-enabling a task must also schedule it forward from now, otherwise a
# paused task with a stale/empty next_run_at would never fire again. # paused task with a stale/empty next_run_at would never fire again.
if update_data.get("enabled") is True and "next_run_at" not in update_data: if update_data.get("enabled") is True and "next_run_at" not in update_data:
@@ -831,6 +902,10 @@ class ScheduledTaskService:
run_id=run_id, run_id=run_id,
output_snapshot=output_snapshot, output_snapshot=output_snapshot,
) )
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files) return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None: async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
@@ -895,6 +970,8 @@ class ScheduledTaskService:
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n" f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
f"{task['prompt']}" f"{task['prompt']}"
) )
if execution_context.get("require_markdown"):
content += _MARKDOWN_INSTRUCTION
body = RunCreateRequest( body = RunCreateRequest(
assistant_id="lead_agent", assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": content}]}, input={"messages": [{"type": "human", "content": content}]},