新增智能体收藏、定时任务必须生成MD校验、问答时间显示及账号绑定的常用提示词

- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口)
- 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移)
- 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角
- 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 authored and xuhd committed 2026-05-28 18:29:32 +08:00
1 parent 1ea521abab
commit 78f6e37f76
43 files changed
+1560 -139

No files matched your search

@@ -9,6 +9,7 @@ import {
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
} from "lucide-react";
@@ -44,6 +45,7 @@ import {
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
} from "@/core/agents";
import type { Agent } from "@/core/agents";
@@ -64,6 +66,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -91,6 +94,10 @@ export function AgentCard({ agent }: AgentCardProps) {
// everyone so toggling published is moot.
const canEdit = 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(() => {
if (!editOpen) {
@@ -197,6 +204,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() {
// 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
@@ -347,8 +364,25 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
<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">
{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 && (
<Button
size="icon"
@@ -89,12 +89,15 @@ export function AgentGallery() {
[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(
() =>
agents.filter(
(a) =>
a.user_id !== null &&
a.user_id === currentUserId &&
((a.user_id !== null && a.user_id === currentUserId) ||
a.is_favorite) &&
matchesSearch(a),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -152,17 +152,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
() =>
adaptBackendReferenceBatches(
backendReferenceBatches,
fallbackReferenceBatches,
skillDisplayMap,
),
[backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap],
[backendReferenceBatches, skillDisplayMap],
);
const referenceBatches = useMemo(
() =>
mergeReferenceBatches(
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
),
() => {
if (adaptedBackendReferenceBatches.length > 0) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
);
useEffect(() => {
@@ -188,6 +188,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
finalBatchCount: referenceBatches.length,
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
backendBatches: debugReferenceBatches(backendReferenceBatches),
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
const latestReferenceBatch =
@@ -199,6 +203,17 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
latestReferenceBatch;
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!ragModeEnabled || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
activeBatchId: activeReferenceBatch.id,
assistantMessageId: activeReferenceBatch.assistant_message_id,
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, ragModeEnabled, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
@@ -503,36 +518,60 @@ function ReferencePanel({
);
}
function debugReferenceBatches(batches: BackendReferenceBatch[]) {
return batches.map((batch) => ({
id: batch.id,
assistantMessageId: batch.assistant_message_id,
sourceCount: batch.sources?.length ?? 0,
sources: (batch.sources ?? []).map(debugReferenceSource),
}));
}
function debugReferenceSource(source: BackendReferenceSource) {
const raw =
source.raw && typeof source.raw === "object" && !Array.isArray(source.raw)
? (source.raw as Record<string, unknown>)
: {};
return {
index: source.index,
id: source.id,
skillName: source.skillName,
mode: source.mode,
title: source.title,
source: source.source,
author: source.author,
time: source.time,
url: source.url,
snippet: source.snippet,
content: source.content,
rawIdCandidates: {
recUuid: raw.recUuid,
rec_uuid: raw.rec_uuid,
uuid: raw.uuid,
docId: raw.docId,
doc_id: raw.doc_id,
id: raw.id,
url: raw.url,
},
raw: source.raw,
};
}
function adaptBackendReferenceBatches(
backendBatches: BackendReferenceBatch[],
configuredBatches: BackendReferenceBatch[],
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return backendBatches;
}
return backendBatches.map((backendBatch, batchIndex) => {
const configuredBatch =
configuredBatches.find(
(batch) =>
batch.id === backendBatch.id ||
batch.assistant_message_id === backendBatch.assistant_message_id,
) ?? configuredBatches[batchIndex];
const configuredSources = configuredBatch?.sources ?? [];
const configuredByIndex = new Map(
configuredSources.map((source) => [source.index, source]),
);
const configuredByKey = new Map(
configuredSources.map((source) => [referenceSourceKey(source), source]),
);
return backendBatches.map((backendBatch) => {
return {
...backendBatch,
sources: backendBatch.sources.map((backendSource, sourceIndex) => {
const configuredSource =
configuredByIndex.get(backendSource.index) ??
configuredByKey.get(referenceSourceKey(backendSource)) ??
configuredSources[sourceIndex] ??
adaptBackendSourceWithSkillDisplay(backendSource, skillDisplayMap);
sources: backendBatch.sources.map((backendSource) => {
const configuredSource = adaptBackendSourceWithSkillDisplay(
backendSource,
skillDisplayMap,
);
if (!configuredSource) {
return backendSource;
}
@@ -551,35 +590,6 @@ function adaptBackendReferenceBatches(
});
}
function mergeReferenceBatches(
backendBatches: BackendReferenceBatch[],
fallbackBatches: BackendReferenceBatch[],
): BackendReferenceBatch[] {
if (backendBatches.length === 0) {
return fallbackBatches;
}
if (fallbackBatches.length === 0) {
return backendBatches;
}
const merged = [...backendBatches];
const knownIds = new Set(
backendBatches.flatMap((batch) =>
[batch.id, batch.assistant_message_id].filter(Boolean),
),
);
for (const fallbackBatch of fallbackBatches) {
if (
knownIds.has(fallbackBatch.id) ||
(fallbackBatch.assistant_message_id &&
knownIds.has(fallbackBatch.assistant_message_id))
) {
continue;
}
merged.push(fallbackBatch);
}
return merged;
}
function adaptBackendSourceWithSkillDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
@@ -657,37 +667,6 @@ function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
return root;
}
function referenceSourceKey(source: BackendReferenceSource): string {
const raw = source.raw ?? {};
const rawRecord =
raw && typeof raw === "object" && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const id =
source.id ||
source.url ||
stringifyKeyValue(rawRecord.recUuid) ||
stringifyKeyValue(rawRecord.uuid) ||
stringifyKeyValue(rawRecord.docId) ||
stringifyKeyValue(rawRecord.id);
if (id) {
return id.trim().toLowerCase();
}
return [source.title, source.source, source.time, source.content, source.snippet]
.filter(Boolean)
.join("|")
.trim()
.toLowerCase();
}
function stringifyKeyValue(value: unknown): string | undefined {
if (typeof value === "string" || typeof value === "number") {
const text = String(value).trim();
return text || undefined;
}
return undefined;
}
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -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";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@@ -304,14 +305,14 @@ export function InputBox({
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -324,12 +325,12 @@ export function InputBox({
}
setFollowups([]);
setFollowupsHidden(false);
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.({
@@ -351,13 +352,13 @@ export function InputBox({
onContextChange,
onSubmit,
onStop,
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
status,
threadId,
],
);
resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking,
status,
threadId,
],
);
const requestFormSubmit = useCallback(() => {
const form = promptRootRef.current?.querySelector("form");
@@ -559,6 +560,21 @@ export function InputBox({
</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")}>
<PromptInput
className={cn(
@@ -651,13 +667,13 @@ export function InputBox({
}
>
<PromptInputButton
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80",
ragModeEnabled
type="button"
aria-pressed={ragModeEnabled}
disabled={ragModeLocked || disabled}
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeLocked && "cursor-not-allowed opacity-80",
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-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>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
@@ -35,6 +35,7 @@ const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
["summaryField", "摘要字段"],
["contentField", "正文内容字段"],
["sourceField", "来源字段"],
["authorField", "作者字段"],
["typeField", "类型字段"],
["timeField", "时间字段"],
["scoreField", "相关度字段"],
@@ -99,7 +99,7 @@ function SourceDetailDialog({
}) {
const content = source?.content || source?.snippet;
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 (
@@ -66,12 +66,13 @@ export function SourceCard({
: null;
const meta = [
source.source,
source.author ? `作者 ${source.author}` : null,
source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
const showReferenceNumber = referenceTarget || isCitation;
const showReferenceNumber = isCitation;
return (
<>
+20
View File
@@ -96,6 +96,26 @@ export async function getAgentSelector(): 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> {
const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`,
+30
View File
@@ -9,6 +9,7 @@ import {
adminSyncAgentSkills,
createAgent,
deleteAgent,
favoriteAgent,
getAgent,
getAgentSelector,
listAgents,
@@ -17,6 +18,7 @@ import {
reorderFeaturedAgents,
setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
updateAgent,
} from "./api";
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() {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"],
+2
View File
@@ -16,6 +16,8 @@ export interface Agent {
skills?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
}
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;
content?: string;
source?: string;
author?: string;
published_date?: string;
publishedDate?: string;
date?: string;
@@ -111,6 +112,15 @@ const SOURCE_ALIASES = [
"kb",
] as const;
const AUTHOR_ALIASES = [
"author",
"authors",
"creator",
"writer",
"byline",
"owner",
] as const;
const DATE_ALIASES = [
"published_date",
"publishedDate",
@@ -131,6 +141,8 @@ const CONTENT_KEY_PATTERN =
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
const SOURCE_KEY_PATTERN =
/(?:^|_)(?: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 =
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
@@ -301,6 +313,12 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
excludeUrl: true,
maxLength: 120,
});
const author =
pickFromLayers(layers, AUTHOR_ALIASES) ??
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
excludeUrl: true,
maxLength: 120,
});
const date =
pickFromLayers(layers, DATE_ALIASES) ??
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 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) {
reserved.add(value);
}
@@ -341,6 +359,7 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
snippet: content,
content,
source,
author,
published_date: date,
publishedDate: date,
date,
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
schedule: string;
timezone?: string | null;
execution_agent_name?: string | null;
require_markdown?: boolean;
}) {
return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), {
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
require_markdown: boolean;
}>,
) {
return parseResponse<ScheduledTask>(
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
require_markdown: boolean;
}>,
) {
return parseResponse<ScheduledTask>(
@@ -20,6 +20,12 @@ export type ScheduledTask = {
cron_expr: string;
timezone: string;
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;
last_run_at?: string | null;
last_status?: string | null;
@@ -10,6 +10,7 @@ export interface DisplaySourceItem {
type: string;
snippet: string;
source?: string;
author?: string;
time?: string;
url?: string;
content?: string;
@@ -25,6 +26,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
summaryField: null,
contentField: null,
sourceField: null,
authorField: null,
typeField: null,
timeField: null,
scoreField: null,
@@ -43,6 +45,7 @@ const candidateFields: Record<string, string[]> = {
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
contentField: ["content", "page_content", "text", "content_preview", "body"],
sourceField: ["source", "source_name", "site", "from", "origin"],
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
typeField: ["type", "category", "source_category", "source1"],
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
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.contentField = inferred.contentField || "content_preview";
inferred.sourceField = inferred.sourceField || "source";
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
inferred.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score";
@@ -127,6 +131,7 @@ function buildDefaultTableColumns(
config.titleField,
config.typeField,
config.sourceField,
config.authorField,
config.timeField,
config.scoreField,
].filter((field): field is string => !!field);
@@ -141,6 +146,16 @@ function buildDefaultTableColumns(
function stringify(value: unknown, fallback = ""): string {
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);
}
@@ -174,6 +189,7 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : 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,
score:
config.scoreField && typeof item[config.scoreField] === "number"
@@ -499,6 +499,7 @@ export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceI
snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet,
source: item.source,
author: item.author,
time: item.published_date ?? item.publishedDate ?? item.date,
url: item.url,
raw: item as unknown as Record<string, unknown>,
+1
View File
@@ -20,6 +20,7 @@ export interface SkillDisplayConfig {
summaryField?: string | null;
contentField?: string | null;
sourceField?: string | null;
authorField?: string | null;
typeField?: string | null;
timeField?: 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 { apiFetch } from "../api";
import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
@@ -378,6 +379,13 @@ export function useThreadStream({
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(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
const prevMsgCountRef = useRef(thread.messages.length);
@@ -429,6 +437,26 @@ export function useThreadStream({
}
}, [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(
async (
threadId: string,
@@ -442,6 +470,20 @@ export function useThreadStream({
sendInFlightRef.current = true;
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
prevMsgCountRef.current = thread.messages.length;
@@ -458,6 +500,7 @@ export function useThreadStream({
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
const optimisticAdditionalKwargs = {
...options?.additionalKwargs,
client_ts: clientTs,
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
};
@@ -535,7 +578,10 @@ export function useThreadStream({
return [
{
...humanMessage,
additional_kwargs: { files: uploadedFiles },
additional_kwargs: {
...humanMessage.additional_kwargs,
files: uploadedFiles,
},
},
...messages.slice(1),
];
@@ -585,6 +631,7 @@ export function useThreadStream({
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
@@ -84,6 +84,7 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
require_markdown: false,
};
export default function ScheduledTasksPage() {
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
timezone: task.timezone,
prompt: task.prompt,
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
// latter may be natural language ("每天9点") that parseCron can't read,
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
});
setSelectedTaskId(task.task_id);
toast.success("任务已创建");
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone,
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
},
});
toast.success("任务已更新");
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
}
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>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
@@ -53,6 +53,7 @@ from app.gateway.routers import (
thread_shares,
threads,
uploads,
user_prompts,
writing,
)
from deerflow.config import app_config as deerflow_app_config
@@ -806,6 +807,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Recommended Questions API is mounted at /api/recommended-questions
app.include_router(recommended_questions.router)
app.include_router(user_prompts.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
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)
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_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -58,6 +58,11 @@ class AgentResponse(BaseModel):
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):
"""Response model for listing all visible agents."""
@@ -355,8 +360,10 @@ async def list_agents(
responses = [_record_to_response(a, include_soul=True) for a in agents]
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:
resp.tags = assignments.get(resp.id, [])
resp.is_favorite = resp.id in favorite_ids
if search:
needle = search.strip().lower()
responses = [r for r in responses if needle in r.name.lower()]
@@ -425,7 +432,10 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
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")
return _record_to_response(record, include_soul=True)
response = _record_to_response(record, include_soul=True)
favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids
return response
except HTTPException:
raise
except Exception as e:
@@ -652,6 +662,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
await get_tag_store(request).unassign_all("agent", agent_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
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)
except HTTPException:
raise
@@ -660,6 +674,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
@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(
"/agents/{agent_id}/featured",
response_model=AgentResponse,
@@ -37,6 +37,10 @@ class ScheduledTaskCreateRequest(BaseModel):
timezone: str | None = None
execution_agent_name: str | 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):
@@ -48,6 +52,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
enabled: bool | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
require_markdown: bool | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
@@ -555,6 +560,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
require_markdown=body.require_markdown,
)
except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
@@ -35,6 +35,7 @@ from deerflow.runtime import serialize_channel_values
from deerflow.runtime.references import (
adapt_reference_batches_with_displays,
build_reference_batches as build_runtime_reference_batches,
build_reference_debug_payload,
build_reference_debug_summary,
)
from deerflow.runtime.user_context import get_effective_user_id
@@ -231,6 +232,7 @@ class ReferenceSourceResponse(BaseModel):
type: str = "result"
snippet: str = ""
source: str | None = None
author: str | None = None
time: str | None = None
url: 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(),
)
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()
if 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),
_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(
batches=[
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
import json
import logging
import os
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_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__)
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
@@ -84,6 +89,18 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
summary["kept_items"],
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 = [
*messages,
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")
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")
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")
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")
@@ -4,10 +4,11 @@ from __future__ import annotations
from deerflow.persistence.agents.base import AgentStore
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
__all__ = [
"AgentFavoriteRow",
"AgentRepository",
"AgentRow",
"AgentStore",
@@ -117,3 +117,25 @@ class AgentStore(ABC):
``False`` if the agent does not exist.
"""
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):
def __init__(self) -> None:
self._agents: dict[str, dict[str, Any]] = {}
self._favorites: set[tuple[str, str]] = set()
@staticmethod
def _is_visible(agent: dict[str, Any], user_id: str) -> bool:
@@ -164,3 +165,20 @@ class MemoryAgentStore(AgentStore):
return False
self._agents.pop(agent_id, None)
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 sqlalchemy import BigInteger, Boolean, String
from sqlalchemy import BigInteger, Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
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)
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))
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 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 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]:
@@ -230,3 +232,47 @@ class AgentRepository(AgentStore):
await session.delete(row)
await session.commit()
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.
"""
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.feedback.model import FeedbackRow
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.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [
"FeedbackRow",
"AgentFavoriteRow",
"AgentRow",
"LlmCallMetricRow",
"NotificationRow",
@@ -57,6 +59,7 @@ __all__ = [
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
"UserCustomPromptRow",
"UserMemoryConfigRow",
"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')}"
meta = " · ".join(
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
)
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. 只能引用上方存在的编号,不要编造编号。",
"4. 不要使用工具原始编号,也不要重新排序编号。",
"5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。",
"6. 参考文献模式下不要输出 <skill-display .../> 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。",
]
)
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(
batches: list[dict[str, Any]],
displays: dict[str, SkillDisplayConfig | dict[str, Any]],
@@ -235,6 +256,65 @@ def adapt_reference_batches_with_displays(
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]]:
sources: 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)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
if isinstance(value, list):
text = _stringify(value, "")
if text:
return text
return None
@@ -604,6 +688,7 @@ def _normalize_with_display_config(
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
content = _stringify(_get_by_path(raw, display.content_field), snippet)
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"
time = _stringify(_get_by_path(raw, display.time_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,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
@@ -667,6 +753,13 @@ def _stringify(value: Any, fallback: str = "") -> str:
if isinstance(value, (str, int, float)):
text = str(value).strip()
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
@@ -705,6 +798,7 @@ def _normalize_reference_item(
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
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"))
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
score_raw = _pick(layers, ("score", "relevance", "similarity"))
@@ -729,6 +823,7 @@ def _normalize_reference_item(
"snippet": content,
"content": content,
"source": source,
"author": author,
"time": time,
"url": url,
"score": score,
@@ -1,4 +1,10 @@
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".
_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):
"""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)
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(
task: dict[str, Any],
*,
@@ -451,6 +508,7 @@ class ScheduledTaskService:
source_agent_name: str | None = None,
execution_agent_name: str | None = None,
execution_context: dict[str, Any] | None = None,
require_markdown: bool = False,
) -> dict[str, Any]:
tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule)
@@ -461,6 +519,7 @@ class ScheduledTaskService:
context = dict(execution_context or {})
if 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
task = await self.store.create_task(
{
@@ -519,6 +578,18 @@ class ScheduledTaskService:
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None:
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
# 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:
@@ -831,6 +902,10 @@ class ScheduledTaskService:
run_id=run_id,
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)
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"{task['prompt']}"
)
if execution_context.get("require_markdown"):
content += _MARKDOWN_INSTRUCTION
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": content}]},