This commit is contained in:
Administrator committed 2026-06-08 19:12:34 +08:00
commit 24db448cfd
10 files changed
+555 -146

No files matched your search

@@ -7,7 +7,10 @@ import {
FileText,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Share2,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
@@ -54,9 +57,17 @@ import {
useDeleteThread,
useRenameThread,
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import {
isThreadFavorite,
isThreadPinned,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
@@ -82,6 +93,8 @@ export function RecentChatList() {
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
// Rename dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
@@ -160,11 +173,13 @@ export function RecentChatList() {
[t],
);
const visibleThreads = threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
const visibleThreads = sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
);
if (visibleThreads.length === 0) {
@@ -195,6 +210,8 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
@@ -210,10 +227,15 @@ export function RecentChatList() {
}
>
<Link
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{desensitize(titleOfThread(thread, t.pages.newChat))}
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -243,6 +265,36 @@ export function RecentChatList() {
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
togglePin(thread.thread_id, !pinned)
}
>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
toggleFavorite(thread.thread_id, !favorited)
}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>
{favorited
? t.common.unfavorite
: t.common.favorite}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
@@ -25,6 +25,10 @@ export const enUS: Translations = {
edit: "Edit",
rename: "Rename",
share: "Share",
pin: "Pin",
unpin: "Unpin",
favorite: "Favorite",
unfavorite: "Unfavorite",
openInNewWindow: "Open in new window",
close: "Close",
more: "More",
@@ -255,6 +259,14 @@ export const enUS: Translations = {
// Chats
chats: {
searchChats: "Search chats",
pinnedSection: "Pinned",
allChats: "All",
favorites: "Favorites",
onlyFavorites: "Favorites only",
prevPage: "Previous",
nextPage: "Next",
noChats: "No conversations yet",
noFavorites: "No favorited conversations yet",
},
// Conversation sharing
@@ -26,6 +26,10 @@ export interface Translations {
edit: string;
rename: string;
share: string;
pin: string;
unpin: string;
favorite: string;
unfavorite: string;
openInNewWindow: string;
close: string;
more: string;
@@ -198,6 +202,14 @@ export interface Translations {
// Chats
chats: {
searchChats: string;
pinnedSection: string;
allChats: string;
favorites: string;
onlyFavorites: string;
prevPage: string;
nextPage: string;
noChats: string;
noFavorites: string;
};
// Conversation sharing
@@ -25,6 +25,10 @@ export const zhCN: Translations = {
edit: "编辑",
rename: "重命名",
share: "分享",
pin: "置顶",
unpin: "取消置顶",
favorite: "收藏",
unfavorite: "取消收藏",
openInNewWindow: "在新窗口打开",
close: "关闭",
more: "更多",
@@ -242,6 +246,14 @@ export const zhCN: Translations = {
// Chats
chats: {
searchChats: "搜索对话",
pinnedSection: "置顶",
allChats: "全部",
favorites: "收藏",
onlyFavorites: "只看收藏",
prevPage: "上一页",
nextPage: "下一页",
noChats: "暂无对话",
noFavorites: "暂无收藏的对话",
},
// Conversation sharing
+116 -36
View File
@@ -621,15 +621,15 @@ export function useThreadStream({
lockThreadRagMode(threadId);
}
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
@@ -647,33 +647,33 @@ export function useThreadStream({
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -853,6 +853,86 @@ export function useDeleteMessage() {
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
* the toggle immediately, mirroring `useRenameThread`.
*/
export function useUpdateThreadMetadata() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
threadId,
metadata,
}: {
threadId: string;
metadata: Record<string, unknown>;
}) => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ metadata }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "更新对话失败。" }));
throw new Error(error.detail ?? "更新对话失败。");
}
},
onSuccess(_, { threadId, metadata }) {
queryClient.setQueriesData(
{
queryKey: ["threads", "search"],
exact: false,
},
(oldData: Array<AgentThread> | undefined) => {
if (oldData == null) {
return oldData;
}
return oldData.map((t) =>
t.thread_id === threadId
? {
...t,
metadata: {
...(t.metadata as Record<string, unknown> | null),
...metadata,
},
}
: t,
);
},
);
},
});
}
/** Toggle a conversation's pinned (置顶) flag. */
export function useToggleThreadPin() {
const { mutate, ...rest } = useUpdateThreadMetadata();
return {
...rest,
toggle: (threadId: string, pinned: boolean) =>
mutate({ threadId, metadata: { is_pinned: pinned } }),
};
}
/** Toggle a conversation's favorite (收藏) flag. */
export function useToggleThreadFavorite() {
const { mutate, ...rest } = useUpdateThreadMetadata();
return {
...rest,
toggle: (threadId: string, favorite: boolean) =>
mutate({ threadId, metadata: { is_favorite: favorite } }),
};
}
export function useRenameThread() {
const queryClient = useQueryClient();
const apiClient = getAPIClient();
+22
View File
@@ -81,6 +81,28 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
return fallback;
}
export function isThreadPinned(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
}
/**
* Stable sort that lifts pinned (置顶) threads to the top while preserving the
* incoming order (typically `updated_at` desc) within both the pinned and the
* unpinned group.
*/
export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
const pinned: AgentThread[] = [];
const rest: AgentThread[] = [];
for (const thread of threads) {
(isThreadPinned(thread) ? pinned : rest).push(thread);
}
return [...pinned, ...rest];
}
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
+255 -95
View File
@@ -1,95 +1,255 @@
"use client";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
export default function ChatsPage() {
const pathOfThread = usePathOfThread();
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
const filteredThreads = useMemo(() => {
return threads?.filter((thread) => {
return (
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
);
});
}, [threads, search]);
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
"use client";
import {
ChevronLeft,
ChevronRight,
DownloadIcon,
Pin,
PinOff,
Star,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import {
isThreadFavorite,
isThreadPinned,
usePathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 20;
export default function ChatsPage() {
const pathOfThread = usePathOfThread();
const { t } = useI18n();
const { data: threads } = useThreads();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [search, setSearch] = useState("");
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
}, [t.pages.chats, t.pages.appName]);
// Reset to the first page whenever the filtered result set changes.
useEffect(() => {
setPage(1);
}, [search, onlyFavorites]);
const filteredThreads = useMemo(() => {
return (threads ?? []).filter((thread) => {
if (
thread.metadata?.thread_type === "scheduler" ||
thread.metadata?.thread_type === "roundtable"
) {
return false;
}
if (onlyFavorites && !isThreadFavorite(thread)) {
return false;
}
return titleOfThread(thread)
.toLowerCase()
.includes(search.toLowerCase());
});
}, [threads, search, onlyFavorites]);
// Pinned conversations are surfaced in their own section and excluded from
// the paginated list so they stay visible regardless of the current page.
const pinnedThreads = useMemo(
() => filteredThreads.filter(isThreadPinned),
[filteredThreads],
);
const restThreads = useMemo(
() => filteredThreads.filter((t) => !isThreadPinned(t)),
[filteredThreads],
);
const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pagedThreads = restThreads.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE,
);
const renderThread = (thread: AgentThread) => {
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className="group/chat-row relative flex flex-col gap-2 border-b"
>
<Link href={pathOfThread(thread)} className="block p-4 pr-24">
<div className="flex items-center gap-1.5">
{pinned && (
<Pin className="size-3.5 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
</div>
{thread.updated_at && (
<div className="text-muted-foreground mt-2 text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</Link>
{/* Row actions — kept out of the Link so clicks don't navigate. */}
<div className="absolute right-3 top-3 flex items-center gap-1 opacity-0 transition-opacity group-hover/chat-row:opacity-100 focus-within:opacity-100">
<Button
variant="ghost"
size="icon"
className="size-8"
title={favorited ? t.common.unfavorite : t.common.favorite}
onClick={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={cn(
"size-4",
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
title={pinned ? t.common.unpin : t.common.pin}
onClick={() => togglePin(thread.thread_id, !pinned)}
>
{pinned ? (
<PinOff className="size-4 text-muted-foreground" />
) : (
<Pin className="size-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
);
};
const isEmpty = filteredThreads.length === 0;
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-between gap-2">
<div className="flex items-center gap-1">
<Button
variant={onlyFavorites ? "outline" : "secondary"}
size="sm"
onClick={() => setOnlyFavorites(false)}
>
{t.chats.allChats}
</Button>
<Button
variant={onlyFavorites ? "secondary" : "outline"}
size="sm"
onClick={() => setOnlyFavorites(true)}
>
<Star className="size-4" />
{t.chats.favorites}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
placeholder={t.chats.searchChats}
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{pinnedThreads.length > 0 && (
<>
<div className="text-muted-foreground/70 px-4 pb-1 pt-2 text-xs font-semibold tracking-wider uppercase">
{t.chats.pinnedSection}
</div>
{pinnedThreads.map(renderThread)}
</>
)}
{pagedThreads.map(renderThread)}
{isEmpty && (
<div className="text-muted-foreground py-16 text-center text-sm">
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3 py-6">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
{t.chats.prevPage}
</Button>
<span className="text-muted-foreground text-sm tabular-nums">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() =>
setPage((p) => Math.min(totalPages, p + 1))
}
>
{t.chats.nextPage}
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
@@ -458,13 +458,36 @@ async def _try_set_session_value(session, sql: str) -> None:
logger.debug("Failed to rollback after analytics session setting failure", exc_info=True)
def _is_mariadb(dialect) -> bool:
"""SQLAlchemy reports ``dialect.name == 'mysql'`` for MariaDB too."""
if getattr(dialect, "_is_mariadb", False) or getattr(dialect, "is_mariadb", False):
return True
version_info = getattr(dialect, "server_version_info", None) or ()
return any("mariadb" in str(part).lower() for part in version_info)
async def _set_analytics_read_timeout(session) -> None:
"""Best-effort per-statement read timeout for the analytics queries.
The right knob differs by server, and some servers have none at all:
- MySQL >= 5.7.4: ``max_execution_time`` (milliseconds)
- MariaDB: ``max_statement_time`` (seconds)
- MySQL <= 5.6: no per-statement timeout variable — skip it entirely
(5.6 raises "Unknown system variable" otherwise)
- PostgreSQL: ``statement_timeout`` (milliseconds)
"""
conn = await session.connection()
dialect = conn.dialect.name
if dialect == "mysql":
await _try_set_session_value(session, f"SET SESSION max_execution_time = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
dialect = conn.dialect
name = dialect.name
if name == "mysql":
if _is_mariadb(dialect):
# MariaDB: max_statement_time is in SECONDS (fractional ok).
await _try_set_session_value(session, f"SET SESSION max_statement_time = {_LEADERBOARD_QUERY_TIMEOUT_MS / 1000}")
elif (getattr(dialect, "server_version_info", None) or ()) >= (5, 7, 4):
await _try_set_session_value(session, f"SET SESSION max_execution_time = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
# else: MySQL <= 5.6 has no statement-timeout variable — skip silently.
await _try_set_session_value(session, f"SET SESSION innodb_lock_wait_timeout = {_LEADERBOARD_LOCK_WAIT_SECONDS}")
elif dialect == "postgresql":
elif name == "postgresql":
await _try_set_session_value(session, f"SET LOCAL statement_timeout = {_LEADERBOARD_QUERY_TIMEOUT_MS}")
@@ -28,6 +28,42 @@ from deerflow.runtime.store.provider import POSTGRES_CONN_REQUIRED, POSTGRES_STO
logger = logging.getLogger(__name__)
def _pool_size_env(name: str, default: int) -> int:
import os
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
return max(0, int(raw))
except ValueError:
logger.warning("Invalid %s=%r; using default %d", name, raw, default)
return default
def _postgres_pool_config() -> dict:
"""Pool config for the Postgres store.
A single long-lived connection (the default ``from_conn_string`` behavior)
dies permanently on a server restart / idle timeout / network blip — every
later store call then fails with "server closed the connection
unexpectedly". A pool with ``check=check_connection`` validates each
connection on checkout and replaces dead ones, so the runtime self-heals.
Extra keys (``check``/``max_idle``/``max_lifetime``) are passed straight
through to ``AsyncConnectionPool`` by ``from_conn_string``.
"""
from psycopg_pool import AsyncConnectionPool
return {
"min_size": _pool_size_env("DEER_FLOW_PG_POOL_MIN", 1),
"max_size": _pool_size_env("DEER_FLOW_PG_POOL_MAX", 10),
"check": AsyncConnectionPool.check_connection,
"max_idle": 300.0,
"max_lifetime": 3600.0,
}
# ---------------------------------------------------------------------------
# Internal backend factory
# ---------------------------------------------------------------------------
@@ -71,9 +107,9 @@ async def _async_store(config) -> AsyncIterator[BaseStore]:
if not config.connection_string:
raise ValueError(POSTGRES_CONN_REQUIRED)
async with AsyncPostgresStore.from_conn_string(config.connection_string) as store:
async with AsyncPostgresStore.from_conn_string(config.connection_string, pool_config=_postgres_pool_config()) as store:
await store.setup()
logger.info("Store: using AsyncPostgresStore")
logger.info("Store: using AsyncPostgresStore (pooled, self-healing)")
yield store
return
+1 -1
View File
@@ -200,7 +200,7 @@ checkpointer:
type: postgres
# true: 新 checkpoint 写入 Postgres;false: 保留 PG 配置但继续使用 SQLite。
# 也可临时用环境变量 DEER_FLOW_USE_PG_CHECKPOINTER=0 覆盖。
use_postgres: false
use_postgres: true
connection_string: postgresql://postgres:postgres123@47.88.25.99:8432/deerflow
sqlite_connection_string: backend/.deer-flow/data/deerflow.db
dual_read_enabled: true