Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
+18
-1
@@ -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")
|
||||||
|
|||||||
+2
-1
@@ -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
|
||||||
+18
@@ -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}
|
||||||
+18
-1
@@ -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))
|
||||||
+48
-2
@@ -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()
|
||||||
+45
@@ -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")
|
||||||
+45
@@ -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")
|
||||||
+4
-1
@@ -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",
|
||||||
]
|
]
|
||||||
+18
@@ -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)
|
||||||
+23
@@ -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))
|
||||||
+74
@@ -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,
|
||||||
|
|||||||
+8
-2
@@ -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",
|
||||||
|
]
|
||||||
+77
@@ -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}]},
|
||||||
|
|||||||
Reference in new issue
Block a user