feat: add fixed questions and improve authentication

This commit is contained in:
吉鑫升 committed 2026-08-21 17:58:39 +08:00
1 parent 8af4fed89d
commit 4c9f75f31e
42 files changed
+3977 -169

No files matched your search

+2
View File
@@ -93,6 +93,8 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep fixed-question exact-match and streaming regression coverage versioned.
!offline-backend-20260512/backend/tests/test_fixed_questions.py
deploy deploy
.history .history
+11 -10
View File
@@ -102,16 +102,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
<QueryClientProvider> <QueryClientProvider>
<I18nProvider initialLocale={locale}> <AuthSyncWatcher disabled={positionStandaloneMode}>
<ThemeProvider> <I18nProvider initialLocale={locale}>
<ChatProvider> <ThemeProvider>
{!positionStandaloneMode && <AuthSyncWatcher />} <ChatProvider>
{!positionStandaloneMode && <DesensitizeWordsLoader />} {!positionStandaloneMode && <DesensitizeWordsLoader />}
<FontScaleApplier /> <FontScaleApplier />
{children} {children}
</ChatProvider> </ChatProvider>
</ThemeProvider> </ThemeProvider>
</I18nProvider> </I18nProvider>
</AuthSyncWatcher>
</QueryClientProvider> </QueryClientProvider>
</NextThemesProvider> </NextThemesProvider>
); );
@@ -10,6 +10,7 @@ import {
FolderOpen, FolderOpen,
Gauge, Gauge,
ListTree, ListTree,
MessageSquareText,
MousePointerClick, MousePointerClick,
BriefcaseBusiness, BriefcaseBusiness,
Settings, Settings,
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
>
<MessageSquareText className="size-4" />
问题配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")} onClick={() => navigate("/page/strategy/admin/position-roles")}
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
<MessageSquareText className="size-4" />
问题配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" /> <BriefcaseBusiness className="size-4" />
@@ -76,6 +76,7 @@ function SelectionContent({
error, error,
source, source,
variant, variant,
readOnly,
}: { }: {
rows: LlmWikiKnowledgeBase[]; rows: LlmWikiKnowledgeBase[];
value: string[]; value: string[];
@@ -86,6 +87,7 @@ function SelectionContent({
error: unknown; error: unknown;
source: SelectorSource; source: SelectorSource;
variant: SelectorVariant; variant: SelectorVariant;
readOnly: boolean;
}) { }) {
const selected = useMemo(() => new Set(value), [value]); const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -97,6 +99,7 @@ function SelectionContent({
}, [rows, search]); }, [rows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]); const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => { const toggle = (id: string) => {
if (readOnly) return;
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]); onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
}; };
@@ -168,6 +171,7 @@ function SelectionContent({
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={item.id} key={item.id}
checked={selected.has(item.id)} checked={selected.has(item.id)}
disabled={readOnly}
onCheckedChange={() => toggle(item.id)} onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()} onSelect={(event) => event.preventDefault()}
className={cn( className={cn(
@@ -183,6 +187,7 @@ function SelectionContent({
type="button" type="button"
role="checkbox" role="checkbox"
aria-checked={selected.has(item.id)} aria-checked={selected.has(item.id)}
disabled={readOnly}
onClick={() => toggle(item.id)} onClick={() => toggle(item.id)}
className={cn( className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary", "my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
@@ -205,15 +210,21 @@ function SelectionContent({
</div> </div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2"> <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span> <span className="text-[11px] text-muted-foreground">
<div className="flex items-center gap-1"> {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}> </span>
全选 {readOnly ? (
</Button> <span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}> ) : (
清空 <div className="flex items-center gap-1">
</Button> <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
</div> 全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
)}
</div> </div>
</> </>
); );
@@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({
agentId, agentId,
autoSelectAll = false, autoSelectAll = false,
disabled = false, disabled = false,
readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
variant = "popover", variant = "popover",
@@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({
agentId?: string | null; agentId?: string | null;
autoSelectAll?: boolean; autoSelectAll?: boolean;
disabled?: boolean; disabled?: boolean;
/** Keep the selected knowledge spaces visible without allowing the thread scope to change. */
readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
@@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({
error={error} error={error}
source={source} source={source}
variant={variant} variant={variant}
readOnly={readOnly}
/> />
); );
@@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({
); );
} }
const selectedName = value.length === 1 const selectedNames = value
? rows.find((item) => item.id === value[0])?.name .map((id) => rows.find((item) => item.id === id)?.name)
: null; .filter((name): name is string => !!name);
const selectedLabel = selectedNames.length > 0
? selectedNames.join("、")
: value.length > 0
? `已选 ${value.length} 个知识空间`
: label;
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
className, className,
)} )}
disabled={disabled || loading} disabled={disabled || loading}
title={value.length > 0 ? selectedLabel : undefined}
> >
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />} {loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedName || label}</span> <span className="truncate text-xs font-normal">{selectedLabel}</span>
{value.length > 0 && ( {value.length > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground"> <span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length} {value.length}
@@ -11,6 +11,7 @@ import {
GaugeIcon, GaugeIcon,
HistoryIcon, HistoryIcon,
MessageCircleQuestionIcon, MessageCircleQuestionIcon,
MessageSquareTextIcon,
ScrollTextIcon, ScrollTextIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
@@ -268,6 +269,16 @@ export function WorkspaceNavMenu() {
推荐问题配置 推荐问题配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/admin/fixed-questions")
}
>
<MessageSquareTextIcon />
问题配置
</DropdownMenuItem>
)}
{isAdmin && logSystem?.enabled && logSystem.url && ( {isAdmin && logSystem?.enabled && logSystem.url && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
+22 -7
View File
@@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : ""; return typeof value === "string" ? value.trim().toLowerCase() : "";
} }
function identityFromLoginName(value: unknown): string | null {
const normalized = normalizeIdentityPart(value);
if (!normalized) return null;
// Explicit email login is domain-specific. Bare username login is the
// stable business principal even if the backend's synthetic email domain or
// internal UUID changes.
return normalized.includes("@")
? `email:${normalized}`
: `account:${normalized}`;
}
function readUserInfoIdentity(): string | null { function readUserInfoIdentity(): string | null {
let info: Record<string, unknown> | null = null; let info: Record<string, unknown> | null = null;
try { try {
@@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null {
/** /**
* A stable cross-tab identity key for "did the account really change?" checks. * A stable cross-tab identity key for "did the account really change?" checks.
* *
* Some external login paths can re-issue a backend ``user_id`` while resolving * Some external login paths can re-issue a backend ``user_id`` or use a
* to the same business account. Prefer business-facing identifiers * different synthetic email domain while resolving to the same business
* (email/username/userInfo) and keep ``user_id`` only as the final fallback. * account. Prefer the raw login username returned by the backend, then the
* email local-part, and keep ``user_id`` only as the final fallback.
*/ */
export function getAccountIdentityKey( export function getAccountIdentityKey(
auth: LoginByUsernameResponse | null = getStoredAuth(), auth: LoginByUsernameResponse | null = getStoredAuth(),
): string | null { ): string | null {
if (!auth) return null; if (!auth) return null;
const email = normalizeIdentityPart(auth.email); const loginIdentity = identityFromLoginName(auth.username);
if (email) return `email:${email}`; if (loginIdentity) return loginIdentity;
const username = normalizeIdentityPart(auth.username); const email = normalizeIdentityPart(auth.email);
if (username) return `username:${username}`; if (email) {
const localPart = email.split("@", 1)[0];
return localPart ? `account:${localPart}` : `email:${email}`;
}
const userInfoIdentity = readUserInfoIdentity(); const userInfoIdentity = readUserInfoIdentity();
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`; if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
+62 -14
View File
@@ -1,9 +1,15 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { Fragment, useEffect, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAccountIdentityKey } from "@/core/auth"; import {
AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName,
getAccountIdentityKey,
} from "@/core/auth";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth"; const AUTH_STORAGE_KEY = "deerflow.auth";
@@ -14,14 +20,37 @@ function readIdentityKey(): string | null {
return getAccountIdentityKey(); return getAccountIdentityKey();
} }
function freshRouteForAccount(pathname: string): string {
// A thread URL belongs to the old account. Keep its workspace/agent context
// but move to a valid new conversation before the user can submit into the
// stale thread. Other pages can stay in place and are remounted below.
const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/);
return chatMatch ? `${chatMatch[1]}/new` : pathname;
}
/** /**
* Mount once at the app root. Watches localStorage auth changes from other * Cross-tab auth boundary for normal pages.
* tabs and lets the user decide when to refresh on account switches. *
* Same-account token re-issuance is ignored. A genuine account change clears
* account-scoped query state, remounts the page, and moves an open chat to its
* fresh-chat route without a full browser reload. Embedded/isolated tabs keep
* their sessionStorage identity and never participate in this synchronization.
*/ */
export function AuthSyncWatcher() { export function AuthSyncWatcher({
children,
disabled = false,
}: {
children?: ReactNode;
disabled?: boolean;
}) {
const lastIdentityRef = useRef<string | null>(readIdentityKey()); const lastIdentityRef = useRef<string | null>(readIdentityKey());
const [revision, setRevision] = useState(0);
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => { useEffect(() => {
if (disabled) return;
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页 // 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。 // 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive() || isIsolatedSession()) return; if (isEmbedActive() || isIsolatedSession()) return;
@@ -36,18 +65,27 @@ export function AuthSyncWatcher() {
lastIdentityRef.current = nextIdentity; lastIdentityRef.current = nextIdentity;
void queryClient.cancelQueries();
queryClient.clear();
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
setRevision((value) => value + 1);
if (nextIdentity === null) { if (nextIdentity === null) {
toast.info("账号已在其它页面退出", {
id: IDENTITY_TOAST_ID,
description: "当前页面已同步退出状态。",
});
navigate("/login", { replace: true });
return; return;
} }
toast.warning("检测到账号在其它标签页发生变化", { const freshRoute = freshRouteForAccount(location.pathname);
if (freshRoute !== location.pathname) {
navigate(freshRoute, { replace: true });
}
toast.info("账号已在页面间自动同步", {
id: IDENTITY_TOAST_ID, id: IDENTITY_TOAST_ID,
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。", description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`,
duration: Infinity,
action: {
label: "刷新同步",
onClick: () => window.location.reload(),
},
}); });
} }
@@ -68,14 +106,24 @@ export function AuthSyncWatcher() {
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS); settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
} }
function handleSameTabAuthChange() {
// `storage` never fires in the tab that called setStoredAuth/clearStoredAuth.
// Keep the comparison baseline current so a later same-account token
// renewal from another normal page is not compared with the pre-login
// `null` identity and falsely classified as an account switch.
lastIdentityRef.current = readIdentityKey();
}
window.addEventListener("storage", handleStorage); window.addEventListener("storage", handleStorage);
window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
return () => { return () => {
window.removeEventListener("storage", handleStorage); window.removeEventListener("storage", handleStorage);
window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange);
if (settleTimer) { if (settleTimer) {
clearTimeout(settleTimer); clearTimeout(settleTimer);
} }
}; };
}, []); }, [disabled, location.pathname, navigate, queryClient]);
return null; return children ? <Fragment key={revision}>{children}</Fragment> : null;
} }
@@ -8,7 +8,7 @@ import {
getAccountIdentityKey, getAccountIdentityKey,
getStoredAuth, getStoredAuth,
} from "@/core/auth"; } from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth"; const AUTH_STORAGE_KEY = "deerflow.auth";
@@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount {
refresh(); refresh();
} }
} }
if (!isEmbedActive()) { // Normal pages share localStorage identity across tabs. Embedded and
// isolated pages deliberately keep their per-tab sessionStorage identity.
if (!isEmbedActive() && !isIsolatedSession()) {
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
} }
window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh); window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh);
+6 -19
View File
@@ -696,16 +696,14 @@ export function useThreadStream({
} }
return; return;
} }
// A "thread not found" 404 almost always means the URL points at a // A "thread not found" 404 means this route cannot accept another turn.
// thread from a previous login session — either this tab outlived a // Move to a fresh chat without a full browser reload; the old reload path
// logout/re-login or the thread genuinely belongs to a different user. // made every click look like a crash and could loop indefinitely when a
// Re-frame the error around the *current* logged-in identity and bounce // different tab's cookie kept overriding this tab's Bearer token.
// the user to a fresh chat instead of leaving them stuck on the stale
// route looking at a raw HTTP 404.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const userLabel = buildCurrentUserLabel(); const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
); );
// Prefer staying inside an agent chat context when the user is on an // Prefer staying inside an agent chat context when the user is on an
// agent thread URL — otherwise fall back to the global new chat. // agent thread URL — otherwise fall back to the global new chat.
@@ -717,18 +715,7 @@ export function useThreadStream({
: iframeMatch : iframeMatch
? "/page/workspace/chats_iframe/new" ? "/page/workspace/chats_iframe/new"
: "/page/workspace/chats/new"; : "/page/workspace/chats/new";
// First navigate to a safe URL so the post-reload page lands on a navigate(target, { replace: true });
// valid route. Then full-reload so TanStack Query cache, context
// providers and any other state captured under the previous user's
// identity get rebuilt against the current login.
if (typeof window !== "undefined") {
window.location.hash = target;
window.setTimeout(() => {
window.location.reload();
}, 900);
} else {
navigate(target, { replace: true });
}
return; return;
} }
// 上一轮还没结束又发了新问题(409)或并发达到上限(429): // 上一轮还没结束又发了新问题(409)或并发达到上限(429):
@@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth";
* 404 just leaves the user staring at a scary error. Both the chat stream * 404 just leaves the user staring at a scary error. Both the chat stream
* (``core/threads/hooks.ts``) and the AI-writing stream * (``core/threads/hooks.ts``) and the AI-writing stream
* (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
* through here so the UX is identical: detect the login-change 404 and prompt * through here so the UX is identical: detect the login-change 404 and reset
* the user to refresh against the current identity instead of erroring out. * the stale in-memory session against the current identity instead of showing
* a raw error or reloading the browser page.
*/ */
/** Normalize an unknown stream error into a human-readable message string. */ /** Normalize an unknown stream error into a human-readable message string. */
@@ -77,22 +78,15 @@ export function buildCurrentUserLabel(): string {
} }
/** /**
* Re-frame a login-change 404 around the current identity and reload the page. * Re-frame a login-change 404 around the current identity and reset its view.
* *
* Shows a friendly toast naming the current login, then full-reloads after a * Streams whose session id lives in component state pass a local reset callback
* short delay so the toast is visible and so TanStack Query cache / context * so they can discard the inaccessible session without a browser reload.
* providers / any state captured under the previous user get rebuilt against
* the current login. Used by streams whose session id lives in component state
* (not the URL) — a plain reload lands them back on a fresh, valid page.
*/ */
export function notifyThreadAccessChangedAndReload(): void { export function notifyThreadAccessChanged(onReset: () => void): void {
const userLabel = buildCurrentUserLabel(); const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`, `当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`,
); );
if (typeof window !== "undefined") { onReset();
window.setTimeout(() => {
window.location.reload();
}, 900);
}
} }
@@ -18,7 +18,7 @@ import { getAPIClient } from '@/core/api';
import { import {
getStreamErrorMessage, getStreamErrorMessage,
isThreadNotAccessibleError, isThreadNotAccessibleError,
notifyThreadAccessChangedAndReload, notifyThreadAccessChanged,
} from '@/core/threads/thread-access-error'; } from '@/core/threads/thread-access-error';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
@@ -299,10 +299,13 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
// thread belongs to a previous login session — the user switched accounts // thread belongs to a previous login session — the user switched accounts
// (or this tab outlived a re-login), so the current account legitimately // (or this tab outlived a re-login), so the current account legitimately
// can't access the old thread. Instead of dumping the raw HTTP 404 into // can't access the old thread. Instead of dumping the raw HTTP 404 into
// the panel, re-frame it around the current identity and reload — same UX // the panel, re-frame it around the current identity and discard the
// the chat stream gives (see core/threads/hooks.ts). // stale component-state session without reloading the browser page.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChangedAndReload(); notifyThreadAccessChanged(() => {
continueTargetRef.current = null;
dispatchRef.current?.({ type: 'reset' });
});
return; return;
} }
dispatchRef.current?.({ type: 'set_error', message }); dispatchRef.current?.({ type: 'set_error', message });
@@ -410,6 +413,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
// (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。 // (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。
const threadId = state.sessionId ?? undefined; const threadId = state.sessionId ?? undefined;
const resetAfterThreadAccessError = useCallback(() => {
setArtifact(null);
seenInterruptRef.current = null;
continueTargetRef.current = null;
dispatch({ type: 'reset' });
}, [setArtifact]);
useEffect(() => { useEffect(() => {
const s = state.status; const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_'); const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
@@ -473,12 +483,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
} catch (err) { } catch (err) {
const message = getStreamErrorMessage(err); const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChangedAndReload(); notifyThreadAccessChanged(resetAfterThreadAccessError);
return; return;
} }
dispatch({ type: 'set_error', message }); dispatch({ type: 'set_error', message });
} }
}, [state.status]); }, [state.status, resetAfterThreadAccessError]);
const submitIntervention = useCallback(async (payload: UserInterventionPayload) => { const submitIntervention = useCallback(async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return; if (!state.sessionId || !state.currentPause) return;
@@ -585,14 +595,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
} catch (err) { } catch (err) {
const message = getStreamErrorMessage(err); const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChangedAndReload(); notifyThreadAccessChanged(resetAfterThreadAccessError);
return; return;
} }
dispatch({ type: 'set_error', message }); dispatch({ type: 'set_error', message });
} }
}, [state.sessionId, state.currentPause, state.materials, state.outline, }, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
state.status]); state.status, resetAfterThreadAccessError]);
const pauseWriting = useCallback(() => { const pauseWriting = useCallback(() => {
// 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。 // 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。
@@ -626,13 +636,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
} catch (err) { } catch (err) {
const message = getStreamErrorMessage(err); const message = getStreamErrorMessage(err);
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
notifyThreadAccessChangedAndReload(); notifyThreadAccessChanged(resetAfterThreadAccessError);
return; return;
} }
dispatch({ type: 'set_error', message }); dispatch({ type: 'set_error', message });
} }
})(); })();
}, [state.request]); }, [state.request, resetAfterThreadAccessError]);
// Restore an unfinished history session: make it active, load its checkpoint, then let // Restore an unfinished history session: make it active, load its checkpoint, then let
// the host skip completed awaiting_* stages or re-run the current unfinished stage. // the host skip completed awaiting_* stages or re-run the current unfinished stage.
+11
View File
@@ -775,6 +775,17 @@ export default function AgentChatPage({
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
readOnly
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
+13 -14
View File
@@ -837,20 +837,19 @@ export default function ChatPage() {
: undefined : undefined
} }
knowledgeSelector={ knowledgeSelector={
isLandingState ? ( <LlmWikiKnowledgeSelector
<LlmWikiKnowledgeSelector source={selectedAgentId ? "agent" : "all"}
source={selectedAgentId ? "agent" : "all"} agentId={selectedAgentId}
agentId={selectedAgentId} value={selectedKnowledgeBaseIds}
value={selectedKnowledgeBaseIds} onChange={changeKnowledgeSelection}
onChange={changeKnowledgeSelection} autoSelectAll={isLandingState && !!selectedAgentId}
autoSelectAll={!!selectedAgentId} disabled={
disabled={ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || knowledgeSelectionLoading
knowledgeSelectionLoading }
} readOnly={!isLandingState}
label="知识空间" label="知识空间"
/> />
) : undefined
} }
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -0,0 +1,310 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
Info,
MessageSquareText,
Plus,
Save,
Search,
Trash2,
Zap,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import {
type FixedQuestion,
useFixedQuestionActions,
useFixedQuestions,
} from "@/strategy-components/api/fixed-questions";
let sequence = 1;
function newId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `fixed-${Date.now()}-${sequence++}`;
}
function serialize(rows: FixedQuestion[]): string {
return JSON.stringify(
rows
.map(({ id, question, answer, enabled, tokensPerSecond, sortOrder }) => ({
id,
question,
answer,
enabled,
tokensPerSecond,
sortOrder,
}))
.sort((a, b) => a.id.localeCompare(b.id)),
);
}
export default function FixedQuestionsPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: serverQuestions = [], isLoading } = useFixedQuestions();
const { save } = useFixedQuestionActions();
const [rows, setRows] = useState<FixedQuestion[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const sourceKey = useMemo(
() => serialize(serverQuestions),
[serverQuestions],
);
useEffect(() => {
if (loadedKey !== sourceKey) {
setRows(serverQuestions);
setLoadedKey(sourceKey);
}
}, [loadedKey, serverQuestions, sourceKey]);
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
const search = query.trim().toLowerCase();
const visibleRows = useMemo(
() =>
search
? rows.filter(
(row) =>
row.question.toLowerCase().includes(search) ||
row.answer.toLowerCase().includes(search),
)
: rows,
[rows, search],
);
const enabledCount = rows.filter(
(row) => row.enabled && row.question.trim() && row.answer.trim(),
).length;
if (!isAdmin) {
return <Navigate to="/page/workspace/chats/new" replace />;
}
const patchRow = (id: string, patch: Partial<FixedQuestion>) => {
setRows((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
};
const addRow = () => {
setRows((current) => [
{
id: newId(),
question: "",
answer: "",
enabled: true,
tokensPerSecond: 100,
sortOrder: current.length,
},
...current,
]);
};
const removeRow = (id: string) => {
setRows((current) => current.filter((row) => row.id !== id));
};
const handleSave = async () => {
const cleaned = rows.map((row, index) => ({
...row,
question: row.question.trim(),
tokensPerSecond: Math.round(row.tokensPerSecond),
sortOrder: index,
}));
for (const row of cleaned) {
if (!row.question) {
toast.error("问题不能为空");
return;
}
if (!row.answer.trim()) {
toast.error(`问题「${row.question}」的回答不能为空`);
return;
}
if (
!Number.isFinite(row.tokensPerSecond) ||
row.tokensPerSecond < 1 ||
row.tokensPerSecond > 1000
) {
toast.error(`问题「${row.question}」的流式速度须为 1–1000 token/s`);
return;
}
}
const seen = new Set<string>();
for (const row of cleaned) {
if (seen.has(row.question)) {
toast.error(`问题「${row.question}」重复,请删除或修改重复项`);
return;
}
seen.add(row.question);
}
try {
await save.mutateAsync(cleaned);
toast.success("问题配置已保存并生效");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<div className="size-full overflow-auto bg-muted/20 px-4 py-6 sm:px-6">
<div className="mx-auto max-w-5xl space-y-5">
<section className="rounded-2xl border bg-background p-5 shadow-sm">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-primary/10 p-2.5 text-primary">
<MessageSquareText className="size-5" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold">问题配置</h1>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
{rows.length} 条 / {enabledCount} 条启用
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={addRow}>
<Plus className="size-4" />
新增问题
</Button>
<Button
onClick={handleSave}
disabled={!dirty || save.isPending}
>
<Save className="size-4" />
{save.isPending ? "保存中…" : "保存配置"}
</Button>
</div>
</div>
<div className="mt-4 flex items-start gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm text-amber-900">
<Info className="mt-0.5 size-4 shrink-0" />
<span>
匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。
</span>
</div>
</section>
<section className="flex flex-col gap-3 rounded-2xl border bg-background p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索问题或回答…"
className="pl-9"
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="size-3.5 text-amber-500" />
启用项保存后立即生效
</div>
</section>
{isLoading ? (
<div className="rounded-2xl border bg-background py-16 text-center text-sm text-muted-foreground">
加载中…
</div>
) : visibleRows.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-background py-16 text-center text-sm text-muted-foreground">
{rows.length === 0
? "暂无问题配置,点击「新增问题」开始配置"
: "没有匹配的问题或回答"}
</div>
) : (
<div className="space-y-3">
{visibleRows.map((row, index) => (
<section
key={row.id}
className={`rounded-2xl border bg-background p-4 shadow-sm transition-opacity ${
row.enabled ? "" : "opacity-60"
}`}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
{index + 1}
</span>
固定问答
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<span>流式速度</span>
<Input
type="number"
min={1}
max={1000}
step={10}
value={row.tokensPerSecond}
onChange={(event) =>
patchRow(row.id, {
tokensPerSecond: Number(event.target.value),
})
}
className="h-8 w-24"
aria-label="流式返回速度"
/>
<span>token/s</span>
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Switch
checked={row.enabled}
onCheckedChange={(enabled) =>
patchRow(row.id, { enabled })
}
/>
{row.enabled ? "已启用" : "已停用"}
</label>
<button
type="button"
title="删除"
onClick={() => removeRow(row.id)}
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="size-4" />
</button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5 text-sm font-medium">
<span>问题</span>
<Textarea
value={row.question}
onChange={(event) =>
patchRow(row.id, { question: event.target.value })
}
placeholder="输入需要精确匹配的问题"
className="min-h-28 resize-y"
/>
</label>
<label className="space-y-1.5 text-sm font-medium">
<span>回答</span>
<Textarea
value={row.answer}
onChange={(event) =>
patchRow(row.id, { answer: event.target.value })
}
placeholder="输入命中后流式返回的回答(支持 Markdown)"
className="min-h-28 resize-y"
/>
</label>
</div>
</section>
))}
</div>
)}
</div>
</div>
);
}
+11
View File
@@ -45,6 +45,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage"; import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage"; import MenuManagementPage from "./MenuManagementPage";
import LoginWhitelistPage from "./LoginWhitelistPage"; import LoginWhitelistPage from "./LoginWhitelistPage";
import FixedQuestionsPage from "./FixedQuestionsPage";
import SensitiveWordsPage from "./SensitiveWordsPage"; import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage"; import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage"; import TaskButtonsPage from "./TaskButtonsPage";
@@ -443,6 +444,16 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route
path="admin/fixed-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<FixedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/sensitive-words" path="admin/sensitive-words"
element={ element={
@@ -453,6 +453,11 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) || (typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
// ask_clarification 是本轮的严格暂停点。某些模型在工具调用已经产出
// 选项卡后还会继续吐一段“请做选择”等正文;从第一张卡出现起忽略后续
// 文本 delta,但继续消费流以收齐同轮的其它澄清卡和终态帧。
let clarificationSeen = false;
const res = await streamIntent({ const res = await streamIntent({
threadId: intentThreadId, threadId: intentThreadId,
message: trimmed, message: trimmed,
@@ -463,6 +468,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
files: fileRefs, files: fileRefs,
signal: aborter.signal, signal: aborter.signal,
onTextDelta: (delta) => { onTextDelta: (delta) => {
if (clarificationSeen) return;
setInteractiveMessages((prev) => setInteractiveMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === agentMsgId m.id === agentMsgId
@@ -505,6 +511,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
); );
}, },
onClarification: (clarification, index) => { onClarification: (clarification, index) => {
clarificationSeen = true;
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现, // 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条 // 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
// 「追问 N:<问题>」,与「强化步骤链」诉求对齐。 // 「追问 N:<问题>」,与「强化步骤链」诉求对齐。
@@ -0,0 +1,122 @@
/** Administrator-managed exact-match fixed question/answer API. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
const BASE = () => `${getBackendBaseURL()}/api/fixed-questions`;
export interface FixedQuestion {
id: string;
question: string;
answer: string;
enabled: boolean;
tokensPerSecond: number;
sortOrder: number;
updatedBy?: string | null;
updatedAt?: string | null;
}
interface ServerFixedQuestion {
id: string;
question: string;
answer: string;
enabled: boolean;
tokens_per_second: number;
sort_order: number;
updated_by?: string | null;
updated_at?: string | null;
}
function fromServer(row: ServerFixedQuestion): FixedQuestion {
return {
id: row.id,
question: row.question ?? "",
answer: row.answer ?? "",
enabled: row.enabled ?? true,
tokensPerSecond: row.tokens_per_second ?? 100,
sortOrder: row.sort_order ?? 0,
updatedBy: row.updated_by ?? null,
updatedAt: row.updated_at ?? null,
};
}
function toServer(row: FixedQuestion): ServerFixedQuestion {
return {
id: row.id,
question: row.question,
answer: row.answer,
enabled: row.enabled,
tokens_per_second: row.tokensPerSecond,
sort_order: row.sortOrder,
};
}
async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) {
const data = await response.json().catch(() => null as unknown);
const detail =
data && typeof data === "object" && "detail" in data
? String((data as { detail: unknown }).detail)
: `${context} failed (${response.status})`;
throw new Error(detail);
}
return (await response.json()) as T;
}
export async function listFixedQuestions(): Promise<FixedQuestion[]> {
const response = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ questions: ServerFixedQuestion[] }>(
response,
"list fixed questions",
);
return (data.questions ?? []).map(fromServer);
}
export async function replaceFixedQuestions(
questions: FixedQuestion[],
): Promise<FixedQuestion[]> {
const response = await apiFetch(BASE(), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ questions: questions.map(toServer) }),
});
const data = await readJson<{ questions: ServerFixedQuestion[] }>(
response,
"save fixed questions",
);
const saved = (data.questions ?? []).map(fromServer);
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-问题配置-保存",
url: "/api/fixed-questions",
reqParam: { questions },
returnParam: saved,
});
return saved;
}
const FIXED_QUESTIONS_KEY = "fixed-questions";
export function useFixedQuestions() {
return useQuery({
queryKey: [FIXED_QUESTIONS_KEY],
queryFn: listFixedQuestions,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function useFixedQuestionActions() {
const queryClient = useQueryClient();
return {
save: useMutation({
mutationFn: replaceFixedQuestions,
onSuccess: (questions) => {
queryClient.setQueryData([FIXED_QUESTIONS_KEY], questions);
},
}),
};
}
@@ -346,6 +346,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 | | **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析任务报告 JSON → report-json → 渲染 `DashboardApp`。Tests: `tests/test_dashboard_sessions.py`。 |
| **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 | | **Task Reports** (`/api/task-reports`) | 任务级研判情报报告详情,**按 `task_id` 共享、不按 user 分权**(写入者只留 `updated_by` 审计)。`GET /by-task/{task_id}` 始终返回原 Consumer 兼容外壳 `{state:"200", msg:"操作成功!", data:[...]}`(未保存为 `data: []`);`POST /by-task/{task_id}` 首次保存,已有报告返回 409;`PUT /by-task/{task_id}` 原子替换完整 `data` 数组。`POST /import/by-task/{task_id}` 面向 TaskCOP 导入页,接收 `sq-report-mock.json` 兼容的 `data`/`records` 数组并完整覆盖目标任务;路径中的选定任务 id 优先于文件内的 `taskId`,避免误写。每一项严格是 `{id, taskId, createTime, updateTime, sessionId, contentJson, categoryType}`;`contentJson` 以 JSON **字符串**原样落在 `records_json`,避免破坏大屏按类目解析的输入格式。保存或导入非空 `data` 后,router 会通过 `app.state.taskcop_task_store.mark_analysis_completed` 将同 id 的 TaskCOP 任务升级至状态 `25`,使旧任务列表显示并筛选为“已完成”;普通保存时不存在匹配任务的报告仍可独立保存,导入则要求目标任务存在。Backed by `deerflow.persistence.task_reports`(table `task_reports`, auto-created by `create_all`);store wired in `deps.py` as `app.state.task_report_store`。前端客户端 `strategy-components/api/task-reports.ts`,`core/auth/dashboard-input.ts` 与 rwfx 3q 完成检查均直接读此接口,不再依赖 Consumer 或本地 sq-report mock。Tests: `tests/test_task_reports.py`。 |
| **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. | | **Task Buttons** (`/api/task-buttons`) | Task-workspace configurable jump buttons (按钮管理), **global/shared** (no user scoping; `updated_by` audit-only). Each row = one jump button in a task deep-link workspace's bottom 快捷跳转 bar: `{id, business (rwfx/3qfx/xdfx), label, link_type (url/business), target, append_task_id, id_param (editable query-param name, e.g. id/task_id), id_kind (task/action — only xdfx differs, its deep-link id is an 行动id), open_mode (blank/self), login_params, append_auth, auth_token_param, auth_name_param, enabled, sort_order}`. **Two independent, coexisting login-append mechanisms for `url` buttons** — both resolved frontend-side: (1) `login_params` (JSON column `login_params_json`, `PortableJSON`, migration `20260625_01`) = a list of `{key, source, custom_value}` resolved at click time (`source` = a `userInfo` field or `other`→literal `custom_value`) and appended to the target URL — mirrors the light-app login params; empty = nothing appended. (2) `append_auth` + `auth_token_param` + `auth_name_param` (columns, migration `20260625_02`, default off) — appends `?{auth_token_param}={token1}&{auth_name_param}={username}` so the target system can identify the current user; gated on **token login** — the frontend only stashes `token1` (the `?authToken=`) + the raw `getTokenInfo` username (`login.token1`/`login.username`) when login went through `/login/token`, so a non-token-login session appends neither. The **frontend** holds code-seed defaults (rwfx's 4 historical buttons built from the live `task_deeplink` config); only when this table is empty do the workspace + admin page fall back to them, else this table is the sole source of truth. `GET /` - list all (open to any authenticated user — the workspace renders them); `PUT /` - **admin only** (`_require_admin`), atomically **replaces the whole set** (`{buttons: [...]}`, de-duped by `id`, last wins). Backed by `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`); store wired in `deps.py` as `app.state.task_button_store`. Frontend: helpers `core/auth/task-buttons.ts`, client `strategy-components/api/task-buttons.ts`, admin page `pages/TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, entry「按钮管理」in the bottom-left settings dropdown, admin-only, both sidebar modes; per-business tabs + 「导入默认」). Tests: `tests/test_task_buttons.py`. |
| **Fixed Questions** (`/api/fixed-questions`) | Administrator-managed exact-match fixed Q&A (问题配置), **global/shared**. Each row is `{id, question, answer, enabled, tokens_per_second, sort_order}`; stream speed is configurable per row from `1–1000 token/s` and defaults to `100`. `GET /` and whole-set atomic `PUT /` are both admin-only. At the start of an external chat run, `services._find_fixed_answer` checks the latest eligible plain-text human message with exact, case/space/punctuation-sensitive equality. Enabled matches switch the run to `make_fixed_answer_agent`: a local `BaseChatModel` tokenizes with `cl100k_base`, replays the answer in rate-limited token chunks, and keeps the existing LangGraph SSE `messages`/`values` protocol, run journal, checkpoint history, deterministic local title, and refresh behavior intact while no provider/model request or model-concurrency slot is used. File/multimodal/context-prefixed turns, `canvas_agent`, `ai_writing`, writing mode, resume commands, and internal orchestration/background child runs bypass matching. Storage failures fail open to the normal model path. Backed by `deerflow.persistence.fixed_questions` (table `fixed_questions`, SHA-256 indexed lookup plus exact original-string collision check, migrations `20260821_01` + `20260821_02`), wired as `app.state.fixed_question_store`. Frontend: `strategy-components/api/fixed-questions.ts`, admin page `pages/FixedQuestionsPage.tsx`, route `/page/strategy/admin/fixed-questions`, entries in both full-layout and compact-workspace settings menus. Tests: `tests/test_fixed_questions.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway. Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
@@ -969,6 +970,8 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`. Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
**Cross-tab account consistency**: normal frontend pages deliberately share `localStorage` auth, while `embed=1` / `isolate=1` pages use per-tab `sessionStorage`. Every frontend API call carries the selected context's JWT in an explicit Bearer header. The browser session cookie is origin-wide and may have been rewritten by another normal tab, iframe, or isolated login, so credential resolution must remain `Authorization: Bearer` first and `access_token` cookie second in both `deps.get_request_access_token()` and `langgraph_auth.authenticate()` (shared parser: `app/gateway/auth/token_source.py`). Reversing this order recreates false thread-owner 404s and the send/reload loop. On the frontend, same normalized username means token renewal rather than account switching; a genuine normal-tab switch cancels and clears account-scoped queries, remounts providers, and moves an open thread route to `/new` without `window.location.reload()`. Embedded/isolated pages ignore cross-tab storage changes. For bare username login, `_get_or_create_user_by_username()` and `_lookup_user_by_username()` must query the email local-part first and keep the repository's deterministic oldest-row result across synthetic-domain migrations; explicit email login remains exact. Regression coverage: `tests/test_health_auth_bypass.py`, `tests/test_username_login_domain.py`.
- `log_system` - Admin-only external log-system entry (`deerflow/config/log_system_config.py`): `{enabled, url, label}`. Surfaced read-only by `GET /api/system-settings/log-system` (admin only); the frontend renders it as a "????" item in the bottom-right settings menu (`workspace-nav-menu.tsx`) that opens `url` in a new tab. Disabled by default. - `log_system` - Admin-only external log-system entry (`deerflow/config/log_system_config.py`): `{enabled, url, label}`. Surfaced read-only by `GET /api/system-settings/log-system` (admin only); the frontend renders it as a "????" item in the bottom-right settings menu (`workspace-nav-menu.tsx`) that opens `url` in a new tab. Disabled by default.
**`extensions_config.json`**: **`extensions_config.json`**:
@@ -41,10 +41,29 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
Health probes are always unauthenticated, even when API authentication is enabled: `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health` all return the Gateway health status without requiring a cookie or bearer token. Health probes are always unauthenticated, even when API authentication is enabled: `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health` all return the Gateway health status without requiring a cookie or bearer token.
Browser authentication is page-context aware. Normal application tabs share the account stored in `localStorage`; `embed=1` and `isolate=1` pages intentionally keep their own account in per-tab `sessionStorage`. Frontend API calls send that selected account as an explicit `Authorization: Bearer` token, and both Gateway and LangGraph authentication give the header priority over the origin-wide session cookie. This prevents a login in another tab or iframe from making the current page query threads as the wrong account. Reissuing a token for the same normalized username is not treated as an account switch; a real switch clears account-scoped frontend state, opens a fresh chat when necessary, and does not reload the whole browser page. Bare username login is domain-agnostic and deterministically reuses the oldest matching account if legacy synthetic email domains coexist; an explicitly supplied email still selects that exact address.
--- ---
## Core Components ## Core Components
### Administrator fixed questions
Administrators can manage exact-match question/answer pairs from the frontend
「问题配置」 page. Each pair has its own enabled switch and configurable fixed-answer
streaming speed (`1–1000 token/s`, default `100 token/s`). For an enabled pair,
a plain-text external chat turn whose text exactly equals the configured
question is answered by a local streaming graph and never sent to a model
provider. The normal LangGraph SSE protocol and checkpoint persistence are
preserved, so the fixed response appears in live chat and conversation history.
Multimodal/file turns, AI Writing/Canvas flows, and internal orchestration child
runs bypass this lookup and continue through their normal model pipeline.
The admin-only configuration API is `GET|PUT /api/fixed-questions`; `PUT`
atomically replaces the full set. Rows are stored in `fixed_questions` (Alembic
revisions `20260821_01` and `20260821_02`) and are looked up with a SHA-256 index plus an exact
original-text comparison.
### Lead Agent ### Lead Agent
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines: The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
@@ -39,6 +39,7 @@ from app.gateway.routers import (
checkpoint_migration, checkpoint_migration,
compact_menu_overrides, compact_menu_overrides,
feedback, feedback,
fixed_questions,
dashboard_sessions, dashboard_sessions,
deep_research, deep_research,
html_page_favorites, html_page_favorites,
@@ -1539,6 +1540,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons # Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router) app.include_router(task_buttons.router)
# Admin exact-match question/answer short-circuit at /api/fixed-questions
app.include_router(fixed_questions.router)
# Temporary compatibility bridge for the TaskCOP situation-overview frontend. # Temporary compatibility bridge for the TaskCOP situation-overview frontend.
app.include_router(taskcop_tasks.router) app.include_router(taskcop_tasks.router)
@@ -0,0 +1,30 @@
"""Resolve the credential that represents the calling browser context.
Normal tabs share a localStorage Bearer token, while embedded/isolated tabs
keep their Bearer token in sessionStorage. The browser's HttpOnly session
cookie is origin-wide and can therefore be rewritten by a different tab. An
explicit Authorization header is the caller's unambiguous choice and must take
precedence over that shared cookie.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol
class _TokenRequest(Protocol):
headers: Mapping[str, str]
cookies: Mapping[str, str]
def get_explicit_or_session_token(request: _TokenRequest) -> str | None:
"""Return Bearer header token first, then the shared session cookie."""
authorization = str(request.headers.get("authorization", "") or "").strip()
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
return token.strip()
cookie_token = str(request.cookies.get("access_token", "") or "").strip()
return cookie_token or None
@@ -100,6 +100,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.run_store = MemoryRunStore() app.state.run_store = MemoryRunStore()
app.state.feedback_repo = None app.state.feedback_repo = None
from deerflow.persistence.fixed_questions import make_fixed_question_store
app.state.fixed_question_store = make_fixed_question_store(sf)
from deerflow.persistence.thread_meta import make_thread_store from deerflow.persistence.thread_meta import make_thread_store
app.state.thread_store = make_thread_store(sf, app.state.store) app.state.thread_store = make_thread_store(sf, app.state.store)
@@ -718,24 +722,22 @@ _cached_repo: SQLiteUserRepository | None = None
def get_request_access_token(request: Request) -> str | None: def get_request_access_token(request: Request) -> str | None:
"""Extract access token from cookie or Authorization header. """Extract access token from Authorization header or session cookie.
Priority: Priority:
1. ``access_token`` cookie (browser session mode) 1. ``Authorization: Bearer <token>`` header (the caller/tab's explicit identity)
2. ``Authorization: Bearer <token>`` header (API client mode) 2. ``access_token`` cookie (browser session fallback)
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only 3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
"""
cookie_token = request.cookies.get("access_token")
if cookie_token:
return cookie_token
authorization = request.headers.get("authorization", "").strip() The header must win over the origin-wide cookie: an iframe or another tab
if authorization: can rewrite that cookie, while the frontend deliberately sources its
scheme, _, token = authorization.partition(" ") Bearer token from the current tab's auth storage.
if scheme.lower() == "bearer": """
token = token.strip() from app.gateway.auth.token_source import get_explicit_or_session_token
if token:
return token request_token = get_explicit_or_session_token(request)
if request_token:
return request_token
from app.gateway.proxy_path import app_relative_path from app.gateway.proxy_path import app_relative_path
@@ -17,6 +17,7 @@ from langgraph_sdk import Auth
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
from app.gateway.auth.errors import TokenError from app.gateway.auth.errors import TokenError
from app.gateway.auth.jwt import decode_token from app.gateway.auth.jwt import decode_token
from app.gateway.auth.token_source import get_explicit_or_session_token
from app.gateway.deps import get_local_provider from app.gateway.deps import get_local_provider
auth = Auth() auth = Auth()
@@ -79,12 +80,9 @@ async def authenticate(request):
# are rejected early, even if the cookie carries a valid JWT. # are rejected early, even if the cookie carries a valid JWT.
_check_csrf(request) _check_csrf(request)
token = request.cookies.get("access_token") # Match Gateway auth exactly: the explicit per-tab Bearer identity wins
if not token: # over the origin-wide cookie that another tab/iframe may have rewritten.
authorization = request.headers.get("authorization", "").strip() token = get_explicit_or_session_token(request)
scheme, _, bearer_token = authorization.partition(" ")
if scheme.lower() == "bearer" and bearer_token.strip():
token = bearer_token.strip()
if not token: if not token:
raise Auth.exceptions.HTTPException( raise Auth.exceptions.HTTPException(
@@ -6,7 +6,7 @@
--- ---
## 0. 四条不可违背的硬规则 ## 0. 五条不可违背的硬规则
### 硬规则 1 · 不替代后续岗位干活 ### 硬规则 1 · 不替代后续岗位干活
@@ -33,6 +33,10 @@
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。 这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
### 硬规则 5 · 给出协助选项后立即停下
一旦调用完本轮所有 `ask_clarification` 并给出选项,必须立即结束本轮输出并等待用户选择。最后一个工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进各工具调用的 `question`、`context` 和 `options` 参数中。
--- ---
## 1. 任务意图字段定义 ## 1. 任务意图字段定义
@@ -6,7 +6,7 @@
--- ---
## 0. 四条不可违背的硬规则(看清楚再开口) ## 0. 五条不可违背的硬规则(看清楚再开口)
**硬规则 1 · 不替代后续席位干活** **硬规则 1 · 不替代后续席位干活**
@@ -36,6 +36,10 @@
- ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答 - ❌ 错误做法:对用户诉求做任何形式的评判、设限、劝阻、回避或拒答
- 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者 - 守门、风控、取舍**都不归你管**——把它们留给 Step 2 的圆桌席位和最终使用者
**硬规则 5 · 给出协助选项后立即停下**
一旦调用 `ask_clarification` 给出本轮选项,必须立即结束本轮输出并等待用户选择。工具调用后禁止再输出任何正文:不补充说明、不复述问题或选项、不催促用户选择、不追加问题,也不输出 `[INTENT_READY]`。需要确认的内容必须全部放进 `question`、`context` 和 `options` 参数中。
--- ---
## 1. 字段定义 ## 1. 字段定义
@@ -11,20 +11,19 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response,
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.config import get_app_config
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
from app.gateway.auth import ( from app.gateway.auth import (
UserResponse, UserResponse,
create_access_token, create_access_token,
) )
from app.gateway.auth.models import User
from app.gateway.auth.config import get_auth_config from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.auth.models import User
from app.gateway.csrf_middleware import is_secure_request from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
from deerflow.config import get_app_config
from deerflow.config.system_settings import load_system_settings
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -242,18 +241,18 @@ async def _get_or_create_user_by_username(username: str):
local_part = _normalize_username_local_part(username) local_part = _normalize_username_local_part(username)
created = False created = False
user = await provider.get_user_by_email(email) # A bare username is globally unique across synthetic email domains. Look
# Domain-agnostic fallback: if no row exists at the canonical # it up domain-agnostically *first* (oldest row wins), even when a newer row
# ``<username>@<current-default-domain>``, look for an existing user with # also exists at today's canonical domain. Choosing the exact canonical
# the same local part under any domain. This is the safety net that keeps # row first made the same username alternate backend user_ids after a
# usernames globally unique when the default email domain changes (e.g. # domain migration, which in turn made its existing threads inaccessible.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default if local_part is not None:
# would let the same username log into two different accounts. user = await provider.find_user_by_email_local_part(local_part)
if user is None and local_part is not None: if user is not None and user.email != email:
legacy = await provider.find_user_by_email_local_part(local_part) logger.info("Matched username %r to stable legacy account %s", username, user.email)
if legacy is not None: else:
user = legacy # An explicitly supplied email intentionally identifies that exact row.
logger.info("Matched username %r to legacy account %s", username, legacy.email) user = await provider.get_user_by_email(email)
if user is None: if user is None:
try: try:
role = "admin" if username == "admin" else "user" role = "admin" if username == "admin" else "user"
@@ -293,10 +292,9 @@ async def _lookup_user_by_username(username: str) -> User | None:
provider = get_local_provider() provider = get_local_provider()
email = _username_to_email(username) email = _username_to_email(username)
local_part = _normalize_username_local_part(username) local_part = _normalize_username_local_part(username)
user = await provider.get_user_by_email(email) if local_part is not None:
if user is None and local_part is not None: return await provider.find_user_by_email_local_part(local_part)
user = await provider.find_user_by_email_local_part(local_part) return await provider.get_user_by_email(email)
return user
def enforce_user_approved(user) -> None: def enforce_user_approved(user) -> None:
@@ -0,0 +1,92 @@
"""Administrator-managed exact-match fixed question/answer API."""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator, model_validator
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.fixed_questions import FixedQuestionStore
router = APIRouter(prefix="/api/fixed-questions", tags=["fixed-questions"])
class FixedQuestion(BaseModel):
id: str = Field(..., min_length=1, max_length=64)
question: str = Field(..., min_length=1, max_length=10_000)
answer: str = Field(..., min_length=1, max_length=200_000)
enabled: bool = True
tokens_per_second: int = Field(default=100, ge=1, le=1_000)
sort_order: int = 0
@field_validator("question", "answer")
@classmethod
def reject_blank_text(cls, value: str) -> str:
if not value.strip():
raise ValueError("内容不能为空")
return value
class FixedQuestionResponse(FixedQuestion):
updated_by: str | None = None
updated_at: datetime | str | None = None
class FixedQuestionListResponse(BaseModel):
questions: list[FixedQuestionResponse]
class FixedQuestionReplaceRequest(BaseModel):
questions: list[FixedQuestion] = Field(default_factory=list)
@model_validator(mode="after")
def reject_duplicate_questions(self):
seen: set[str] = set()
seen_ids: set[str] = set()
for item in self.questions:
if item.id in seen_ids:
raise ValueError(f"配置 ID「{item.id}」重复")
seen_ids.add(item.id)
if item.question in seen:
raise ValueError(f"问题「{item.question}」重复")
seen.add(item.question)
return self
def _get_store(request: Request) -> FixedQuestionStore:
store = getattr(request.app.state, "fixed_question_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Fixed question store not available")
return store
async def _require_admin(request: Request) -> None:
"""Require an administrator, while preserving auth-disabled deployments."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="问题配置仅限管理员")
@router.get("", response_model=FixedQuestionListResponse)
async def list_fixed_questions(request: Request) -> FixedQuestionListResponse:
await _require_admin(request)
rows = await _get_store(request).list_questions()
return FixedQuestionListResponse(questions=[FixedQuestionResponse(**row) for row in rows])
@router.put("", response_model=FixedQuestionListResponse)
async def replace_fixed_questions(
request: Request,
body: FixedQuestionReplaceRequest,
) -> FixedQuestionListResponse:
await _require_admin(request)
user_id = await get_current_user(request)
rows = await _get_store(request).replace_all(
[question.model_dump() for question in body.questions],
updated_by=user_id,
)
return FixedQuestionListResponse(questions=[FixedQuestionResponse(**row) for row in rows])
@@ -29,7 +29,8 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
- ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值; - ``done``:已解析 ``[INTENT_READY]`` + JSON 块,``summary`` 有值;
- ``error``:上游或网关异常。 - ``error``:上游或网关异常。
``content`` — 展示用 AI 正文(澄清时优先用 tool 后的 follow-up 文本)。 ``content`` — 展示用 AI 正文(澄清时只保留 tool 调用前的简短引导;
tool 后的尾随文本会被丢弃,选项发出后立即等待用户选择)。
仅 ``status == "done"`` 时带 ``summary``;澄清时还带 仅 ``status == "done"`` 时带 ``summary``;澄清时还带
``question``、``clarification_type``、``clarification_context``、 ``question``、``clarification_type``、``clarification_context``、
@@ -625,6 +626,14 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。 # 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。 # 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。" effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
if past_rounds < max_rounds:
effective_message = (
"[澄清输出硬约束] 如果本轮调用 ask_clarification,"
"工具调用完成后必须立即停止输出并等待用户选择;"
"禁止补充说明、复述选项、催促选择、追加问题或输出 "
"[INTENT_READY]。需要确认的全部内容只能写入工具参数。\n\n"
f"用户最新输入:{effective_message}"
)
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds: if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
# The position flow needs an actionable four-part hand-off, # The position flow needs an actionable four-part hand-off,
# not merely a recognizable topic. One turn may ask several # not merely a recognizable topic. One turn may ask several
@@ -637,6 +646,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"ask_clarification;每个工具调用只能问一个决策点,不能把多项" "ask_clarification;每个工具调用只能问一个决策点,不能把多项"
"问题合并成一张卡片。用户会一次性回答这些卡片。" "问题合并成一张卡片。用户会一次性回答这些卡片。"
"如果四项意图已经足够明确,则直接输出最终结果。" "如果四项意图已经足够明确,则直接输出最终结果。"
"一旦调用完本轮所有 ask_clarification,必须立即停止输出,"
"不要再补充说明、复述选项、催促选择或继续提问,等待用户选择。"
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。" "不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有" "本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n" "剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
@@ -756,9 +767,6 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
content = _flatten_content(dispatch_msg.get("content", "")) content = _flatten_content(dispatch_msg.get("content", ""))
if clarification_calls: if clarification_calls:
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
clarification_payloads: list[dict[str, Any]] = [] clarification_payloads: list[dict[str, Any]] = []
for clarification_call in clarification_calls: for clarification_call in clarification_calls:
cargs = clarification_call.get("args") or {} cargs = clarification_call.get("args") or {}
@@ -790,6 +798,9 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
) )
if not clarification_payloads: if not clarification_payloads:
# 这里只在所有卡片都因重复而被过滤时才允许使用 tool 后文本;
# 此时前端没有选项需要等待,不属于澄清暂停态。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
fallback_msg = ( fallback_msg = (
follow_up follow_up
or content or content
@@ -805,7 +816,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
return return
first = clarification_payloads[0] first = clarification_payloads[0]
display_content = follow_up or content or str(first["question"]) # ask_clarification 是严格的回合终点。部分模型会在工具调用后
# 再生成“请从以上选项中选择……”等尾随 AIMessage;不能把它
# 返回给前端,否则用户看到卡片后还会继续冒出一段话。
# 仅保留工具调用自身携带的前置短句;没有则直接由卡片展示问题。
display_content = content
final_frame: dict[str, Any] = { final_frame: dict[str, Any] = {
"status": "clarification", "status": "clarification",
"content": display_content, "content": display_content,
@@ -22,6 +22,7 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge from app.gateway.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge
from app.gateway.llmwiki_rag import ( from app.gateway.llmwiki_rag import (
LlmWikiRagContext,
attach_llmwiki_rag_to_graph_input, attach_llmwiki_rag_to_graph_input,
build_llmwiki_rag_context, build_llmwiki_rag_context,
llmwiki_selection_requested, llmwiki_selection_requested,
@@ -227,6 +228,77 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
return raw_input return raw_input
def _plain_fixed_question(graph_input: dict[str, Any]) -> str | None:
"""Return the latest plain-text human turn eligible for exact matching.
Messages carrying files or multimodal blocks are intentionally excluded:
their answer depends on more than the visible question text, so replaying a
global fixed answer would be surprising and unsafe.
"""
messages = graph_input.get("messages")
if not isinstance(messages, list) or not messages:
return None
message = messages[-1]
if not isinstance(message, HumanMessage):
return None
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
if any(
additional_kwargs.get(key)
for key in ("files", "uploaded_files", "attachments", "prompt_prefix")
):
return None
content = getattr(message, "content", None)
if isinstance(content, str):
return content
if not isinstance(content, list):
return None
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
continue
if not isinstance(item, Mapping):
return None
item_type = str(item.get("type") or "text")
text = item.get("text")
if item_type not in {"text", "input_text"} or not isinstance(text, str):
return None
parts.append(text)
return "".join(parts)
async def _find_fixed_answer(
*,
body: Any,
request: Request,
graph_input: dict[str, Any],
run_scope: str,
) -> dict[str, Any] | None:
"""Look up an enabled exact match, falling back to the model on errors."""
if run_scope != RUN_SCOPE_EXTERNAL_ROOT or getattr(body, "command", None) is not None:
return None
assistant_id = str(getattr(body, "assistant_id", None) or _DEFAULT_ASSISTANT_ID)
if assistant_id in {"canvas_agent", "ai_writing"}:
return None
context = getattr(body, "context", None) or {}
if isinstance(context, Mapping) and context.get("writing_mode"):
return None
question = _plain_fixed_question(graph_input)
if question is None:
return None
store = getattr(request.app.state, "fixed_question_store", None)
if store is None:
return None
try:
return await store.find_enabled(question)
except Exception:
# Configuration storage must never make normal chat unavailable.
logger.warning("Fixed-question lookup failed; continuing with model", exc_info=True)
return None
_DEFAULT_ASSISTANT_ID = "lead_agent" _DEFAULT_ASSISTANT_ID = "lead_agent"
# Built-in LangGraph graph IDs registered in langgraph.json. # Built-in LangGraph graph IDs registered in langgraph.json.
@@ -865,21 +937,36 @@ async def start_run(
await _enforce_agent_access(body, request) await _enforce_agent_access(body, request)
t_post_enforce = time.perf_counter() t_post_enforce = time.perf_counter()
effective_model_name = _resolve_effective_run_model_name(body)
effective_user_id = get_effective_user_id() effective_user_id = get_effective_user_id()
effective_run_scope = _resolve_run_scope(body, request, run_scope) effective_run_scope = _resolve_run_scope(body, request, run_scope)
model_concurrency_limit = _get_model_concurrency_limit(effective_model_name) graph_input = normalize_input(body.input)
user_model_concurrency_limit = ( fixed_answer = await _find_fixed_answer(
_get_user_model_concurrency_limit(effective_model_name) body=body,
if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT request=request,
else None graph_input=graph_input,
run_scope=effective_run_scope,
) )
if fixed_answer is not None:
effective_model_name = "fixed-answer"
model_concurrency_limit = None
user_model_concurrency_limit = None
else:
effective_model_name = _resolve_effective_run_model_name(body)
model_concurrency_limit = _get_model_concurrency_limit(effective_model_name)
user_model_concurrency_limit = (
_get_user_model_concurrency_limit(effective_model_name)
if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT
else None
)
run_id = str(uuid.uuid4()) run_id = str(uuid.uuid4())
run_metadata = dict(body.metadata or {}) run_metadata = dict(body.metadata or {})
run_metadata["model_name"] = effective_model_name run_metadata["model_name"] = effective_model_name
run_metadata["user_id"] = effective_user_id run_metadata["user_id"] = effective_user_id
run_metadata["run_scope"] = effective_run_scope run_metadata["run_scope"] = effective_run_scope
if fixed_answer is not None:
run_metadata["fixed_answer"] = True
run_metadata["fixed_question_id"] = fixed_answer["id"]
redis_lease_acquired = False redis_lease_acquired = False
redis_gate_degraded = True redis_gate_degraded = True
@@ -891,7 +978,7 @@ async def start_run(
"user_id": effective_user_id, "user_id": effective_user_id,
"user_model_accounted": user_model_accounted, "user_model_accounted": user_model_accounted,
} }
if concurrency_gate is not None: if concurrency_gate is not None and fixed_answer is None:
acquire = await concurrency_gate.try_acquire( acquire = await concurrency_gate.try_acquire(
run_id=run_id, run_id=run_id,
thread_id=thread_id, thread_id=thread_id,
@@ -992,12 +1079,22 @@ async def start_run(
t_post_thread_upsert = time.perf_counter() t_post_thread_upsert = time.perf_counter()
try: try:
agent_factory = resolve_agent_factory(body.assistant_id) if fixed_answer is not None:
from deerflow.agents.fixed_answer_agent import make_fixed_answer_agent
def agent_factory(config): # noqa: ANN001, ARG001 - worker factory contract
return make_fixed_answer_agent(
question=str(fixed_answer["question"]),
answer=str(fixed_answer["answer"]),
fixed_question_id=str(fixed_answer["id"]),
tokens_per_second=int(fixed_answer.get("tokens_per_second", 100)),
)
else:
agent_factory = resolve_agent_factory(body.assistant_id)
except Exception: except Exception:
if concurrency_gate is not None and redis_lease_acquired: if concurrency_gate is not None and redis_lease_acquired:
await concurrency_gate.release(**lease_metadata) await concurrency_gate.release(**lease_metadata)
raise raise
graph_input = normalize_input(body.input)
# Admin-configurable prompt prefix: when the caller opted in via # Admin-configurable prompt prefix: when the caller opted in via
# context.prompt_prefix_enabled, tag the human message with the configured # context.prompt_prefix_enabled, tag the human message with the configured
@@ -1007,14 +1104,18 @@ async def start_run(
# applies once. We intentionally do NOT gate on thread-row existence: the # applies once. We intentionally do NOT gate on thread-row existence: the
# thread_meta row is frequently pre-created, which previously skipped the # thread_meta row is frequently pre-created, which previously skipped the
# prefix for genuine first turns. # prefix for genuine first turns.
if _prompt_prefix_opted_in(body): if fixed_answer is None and _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip(): if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip()) graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
llmwiki_rag = await build_llmwiki_rag_context( llmwiki_rag = (
request=request, LlmWikiRagContext([], 0, "", [], "")
body=body, if fixed_answer is not None
graph_input=graph_input, else await build_llmwiki_rag_context(
request=request,
body=body,
graph_input=graph_input,
)
) )
if llmwiki_rag.prompt or llmwiki_rag.sources: if llmwiki_rag.prompt or llmwiki_rag.sources:
graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag) graph_input = attach_llmwiki_rag_to_graph_input(graph_input, llmwiki_rag)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,186 @@
"""Small local streaming graph used for exact-match fixed answers.
The graph deliberately uses a local ``BaseChatModel`` implementation so
LangGraph emits the same token/message stream events as a normal model-backed
answer, while making no network or model-provider request.
"""
from __future__ import annotations
import asyncio
import codecs
import math
import uuid
from collections.abc import AsyncIterator, Sequence
from typing import Any
import tiktoken
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, START, StateGraph
from deerflow.agents.thread_state import ThreadState
_TOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
_TARGET_CHUNKS_PER_SECOND = 10
_MAX_TOKENS_PER_CHUNK = 20
_MAX_TOKENS_PER_SECOND = 1_000
class FixedAnswerChatModel(BaseChatModel):
"""A local streaming model that only replays an administrator answer."""
answer: str
message_id: str
fixed_question_id: str
tokens_per_second: int = 100
@property
def _llm_type(self) -> str:
return "fixed-answer"
@property
def _identifying_params(self) -> dict[str, Any]:
return {
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": self.tokens_per_second,
}
def _message(self) -> AIMessage:
return AIMessage(
content=self.answer,
id=self.message_id,
additional_kwargs={
"fixed_answer": True,
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": self.tokens_per_second,
},
)
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
del messages, stop, run_manager, kwargs
return ChatResult(generations=[ChatGeneration(message=self._message())])
async def _astream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
del messages, stop, run_manager, kwargs
answer = self.answer
if not answer:
yield ChatGenerationChunk(message=AIMessageChunk(content="", id=self.message_id))
return
token_ids = _TOKEN_ENCODING.encode(answer, disallowed_special=())
tokens_per_second = max(
1,
min(_MAX_TOKENS_PER_SECOND, self.tokens_per_second),
)
chunk_size = max(
1,
min(
_MAX_TOKENS_PER_CHUNK,
math.ceil(tokens_per_second / _TARGET_CHUNKS_PER_SECOND),
),
)
decoder = codecs.getincrementaldecoder("utf-8")()
token_groups = [token_ids[:1], *(token_ids[offset : offset + chunk_size] for offset in range(1, len(token_ids), chunk_size))]
for index, chunk_token_ids in enumerate(token_groups):
if index > 0:
await asyncio.sleep(len(chunk_token_ids) / tokens_per_second)
chunk_bytes = b"".join(_TOKEN_ENCODING.decode_single_token_bytes(token_id) for token_id in chunk_token_ids)
is_last = index == len(token_groups) - 1
content = decoder.decode(chunk_bytes, final=is_last)
chunk_kwargs = (
{
"fixed_answer": True,
"fixed_question_id": self.fixed_question_id,
"tokens_per_second": tokens_per_second,
}
if index == 0
else {}
)
yield ChatGenerationChunk(
message=AIMessageChunk(
content=content,
id=self.message_id,
additional_kwargs=chunk_kwargs,
)
)
def _fallback_title(question: str) -> str:
if len(question) > 50:
return question[:50].rstrip() + "..."
return question or "New Conversation"
def make_fixed_answer_agent(
*,
question: str,
answer: str,
fixed_question_id: str,
tokens_per_second: int = 100,
):
"""Build a one-node graph that streams and persists a fixed answer."""
tokens_per_second = max(
1,
min(_MAX_TOKENS_PER_SECOND, int(tokens_per_second)),
)
message_id = f"fixed-answer-{uuid.uuid4()}"
model = FixedAnswerChatModel(
answer=answer,
message_id=message_id,
fixed_question_id=fixed_question_id,
tokens_per_second=tokens_per_second,
).with_config(tags=["fixed-answer"])
async def respond(
state: ThreadState,
config: RunnableConfig,
) -> dict[str, Any]:
messages: Sequence[BaseMessage] = state.get("messages", [])
# Calling the local model through ``astream`` makes the runtime publish
# normal messages-mode chunks. The full AIMessage returned below is the
# durable checkpoint value used on refresh/history reads.
async for _chunk in model.astream(list(messages), config=config):
pass
update: dict[str, Any] = {
"messages": [
AIMessage(
content=answer,
id=message_id,
additional_kwargs={
"fixed_answer": True,
"fixed_question_id": fixed_question_id,
"tokens_per_second": tokens_per_second,
},
)
]
}
if not state.get("title"):
update["title"] = _fallback_title(question)
update["title_provisional"] = False
elif state.get("title_provisional") is True:
# A fixed-answer run must not trigger the worker's background LLM
# title finalizer either; keep the already-visible local title.
update["title_provisional"] = False
return update
graph = StateGraph(ThreadState)
graph.add_node("fixed_answer", respond)
graph.add_edge(START, "fixed_answer")
graph.add_edge("fixed_answer", END)
return graph.compile()
@@ -0,0 +1,22 @@
"""Exact-match fixed-question persistence."""
from __future__ import annotations
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
from deerflow.persistence.fixed_questions.memory import MemoryFixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.fixed_questions.sql import FixedQuestionRepository
__all__ = [
"FixedQuestionRepository",
"FixedQuestionRow",
"FixedQuestionStore",
"MemoryFixedQuestionStore",
"make_fixed_question_store",
]
def make_fixed_question_store(session_factory):
if session_factory is None:
return MemoryFixedQuestionStore()
return FixedQuestionRepository(session_factory)
@@ -0,0 +1,30 @@
"""Abstract persistence API for administrator-managed fixed questions."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class FixedQuestionStore(ABC):
"""Global fixed-question configuration shared by every account."""
@abstractmethod
async def list_questions(self) -> list[dict[str, Any]]:
"""Return every configured question in display order."""
raise NotImplementedError
@abstractmethod
async def find_enabled(self, question: str) -> dict[str, Any] | None:
"""Return the enabled row whose question exactly equals ``question``."""
raise NotImplementedError
@abstractmethod
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
"""Atomically replace the complete fixed-question configuration."""
raise NotImplementedError
@@ -0,0 +1,56 @@
"""In-memory fixed-question store used by memory deployments and tests."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
return {
"id": str(data["id"]),
"question": str(data.get("question") or ""),
"answer": str(data.get("answer") or ""),
"enabled": bool(data.get("enabled", True)),
"tokens_per_second": int(data.get("tokens_per_second", 100)),
"sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by,
"updated_at": datetime.now(UTC).isoformat(),
}
class MemoryFixedQuestionStore(FixedQuestionStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_questions(self) -> list[dict[str, Any]]:
rows = sorted(
self._rows.values(),
key=lambda row: (row.get("sort_order", 0), row.get("question", "")),
)
return [dict(row) for row in rows]
async def find_enabled(self, question: str) -> dict[str, Any] | None:
for row in self._rows.values():
if row["enabled"] and row["question"] == question:
return dict(row)
return None
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
# Last duplicate question wins, mirroring the SQL repository.
by_id: dict[str, dict[str, Any]] = {}
for row in rows:
question = row.get("question")
row_id = row.get("id")
if row_id and question not in (None, ""):
by_id[str(row_id)] = row
by_question = {str(row["question"]): row for row in by_id.values()}
self._rows = {str(row["id"]): _normalize(row, updated_by) for row in by_question.values()}
return await self.list_questions()
@@ -0,0 +1,41 @@
"""ORM model for exact-match, administrator-managed fixed questions."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class FixedQuestionRow(Base):
"""One globally shared question/answer pair.
``question_hash`` provides a portable indexed lookup for questions stored in
``Text``. The repository always compares the original question as well, so a
theoretical SHA-256 collision cannot cause a false match.
"""
__tablename__ = "fixed_questions"
__table_args__ = (UniqueConstraint("question_hash", name="uq_fixed_questions_question_hash"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
question: Mapped[str] = mapped_column(Text, nullable=False)
question_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
answer: Mapped[str] = mapped_column(PortableLongText, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
tokens_per_second: Mapped[int] = mapped_column(
Integer,
default=100,
server_default="100",
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,88 @@
"""SQLAlchemy-backed fixed-question store."""
from __future__ import annotations
import hashlib
from datetime import datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.fixed_questions.base import FixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
def question_hash(question: str) -> str:
return hashlib.sha256(question.encode("utf-8")).hexdigest()
def _to_dict(row: FixedQuestionRow) -> dict[str, Any]:
data = row.to_dict()
data.pop("question_hash", None)
value = data.get("updated_at")
if isinstance(value, datetime):
data["updated_at"] = value.isoformat()
return data
def _row_from(data: dict[str, Any], updated_by: str | None) -> FixedQuestionRow:
question = str(data.get("question") or "")
return FixedQuestionRow(
id=str(data["id"]),
question=question,
question_hash=question_hash(question),
answer=str(data.get("answer") or ""),
enabled=bool(data.get("enabled", True)),
tokens_per_second=int(data.get("tokens_per_second", 100)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,
)
class FixedQuestionRepository(FixedQuestionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_questions(self) -> list[dict[str, Any]]:
stmt = select(FixedQuestionRow).order_by(
FixedQuestionRow.sort_order.asc(),
FixedQuestionRow.question.asc(),
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def find_enabled(self, question: str) -> dict[str, Any] | None:
stmt = select(FixedQuestionRow).where(
FixedQuestionRow.question_hash == question_hash(question),
FixedQuestionRow.enabled.is_(True),
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None or row.question != question:
return None
return _to_dict(row)
async def replace_all(
self,
rows: list[dict[str, Any]],
*,
updated_by: str | None = None,
) -> list[dict[str, Any]]:
# Last duplicate question wins, preventing a unique-hash violation even
# if a non-UI caller submits malformed data.
by_id: dict[str, dict[str, Any]] = {}
for row in rows:
question = row.get("question")
row_id = row.get("id")
if row_id and question not in (None, ""):
by_id[str(row_id)] = row
by_question = {str(row["question"]): row for row in by_id.values()}
async with self._sf() as session:
await session.execute(delete(FixedQuestionRow))
new_rows = [_row_from(row, updated_by) for row in by_question.values()]
session.add_all(new_rows)
await session.commit()
return [_to_dict(row) for row in new_rows]
@@ -0,0 +1,51 @@
"""Create administrator-managed exact-match fixed questions.
Revision ID: 20260821_01
Revises: 20260816_01
Create Date: 2026-08-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
revision = "20260821_01"
down_revision = "20260816_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("fixed_questions"):
return
long_text = sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
op.create_table(
"fixed_questions",
sa.Column("id", sa.String(length=64), primary_key=True),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("question_hash", sa.String(length=64), nullable=False),
sa.Column("answer", long_text, nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("updated_by", sa.String(length=64), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"question_hash",
name="uq_fixed_questions_question_hash",
),
)
op.create_index(
"ix_fixed_questions_question_hash",
"fixed_questions",
["question_hash"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
if sa.inspect(bind).has_table("fixed_questions"):
op.drop_table("fixed_questions")
@@ -0,0 +1,44 @@
"""Add configurable fixed-answer streaming speed.
Revision ID: 20260821_02
Revises: 20260821_01
Create Date: 2026-08-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260821_02"
down_revision = "20260821_01"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("fixed_questions"):
return
columns = {column["name"] for column in inspector.get_columns("fixed_questions")}
if "tokens_per_second" not in columns:
op.add_column(
"fixed_questions",
sa.Column(
"tokens_per_second",
sa.Integer(),
nullable=False,
server_default="100",
),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("fixed_questions"):
return
columns = {column["name"] for column in inspector.get_columns("fixed_questions")}
if "tokens_per_second" in columns:
op.drop_column("fixed_questions", "tokens_per_second")
@@ -47,6 +47,7 @@ from deerflow.persistence.deep_research_sources.model import DeepResearchSourceR
from deerflow.persistence.document_rewrite_versions.model import DocumentRewriteVersionRow from deerflow.persistence.document_rewrite_versions.model import DocumentRewriteVersionRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.intent_turns.model import IntentTurnRow from deerflow.persistence.intent_turns.model import IntentTurnRow
from deerflow.persistence.light_apps.model import LightAppRow from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
@@ -97,6 +98,7 @@ from deerflow.persistence.user_prompts.model import UserCustomPromptRow
__all__ = [ __all__ = [
"FeedbackRow", "FeedbackRow",
"FixedQuestionRow",
"AgentExtrasRow", "AgentExtrasRow",
"AgentFavoriteRow", "AgentFavoriteRow",
"AgentRow", "AgentRow",
@@ -0,0 +1,288 @@
"""Regression tests for administrator-managed exact-match fixed answers."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from langchain_core.messages import HumanMessage
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers.fixed_questions import (
FixedQuestion,
FixedQuestionReplaceRequest,
)
from app.gateway.services import (
RUN_SCOPE_EXTERNAL_ROOT,
RUN_SCOPE_ORCHESTRATION_CHILD,
_find_fixed_answer,
normalize_input,
)
from deerflow.agents import fixed_answer_agent as fixed_answer_module
from deerflow.agents.fixed_answer_agent import make_fixed_answer_agent
from deerflow.persistence.fixed_questions import MemoryFixedQuestionStore
from deerflow.persistence.fixed_questions.model import FixedQuestionRow
from deerflow.persistence.fixed_questions.sql import FixedQuestionRepository
def _body(**overrides):
data = {
"assistant_id": "lead_agent",
"command": None,
"context": {},
}
data.update(overrides)
return SimpleNamespace(**data)
def _request(store: MemoryFixedQuestionStore):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(fixed_question_store=store),
)
)
@pytest.mark.asyncio
async def test_memory_store_matches_only_enabled_exact_question() -> None:
store = MemoryFixedQuestionStore()
await store.replace_all(
[
{
"id": "enabled",
"question": "系统开放时间?",
"answer": "工作日 09:00—18:00。",
"enabled": True,
"tokens_per_second": 80,
"sort_order": 0,
},
{
"id": "disabled",
"question": "停用问题",
"answer": "不应返回",
"enabled": False,
"sort_order": 1,
},
]
)
match = await store.find_enabled("系统开放时间?")
assert match is not None
assert match["answer"] == "工作日 09:00—18:00。"
assert match["tokens_per_second"] == 80
assert await store.find_enabled("系统开放时间?") is None
assert await store.find_enabled(" 系统开放时间?") is None
assert await store.find_enabled("停用问题") is None
@pytest.mark.asyncio
async def test_sql_store_round_trips_and_exact_matches() -> None:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(FixedQuestionRow.__table__.create)
repository = FixedQuestionRepository(async_sessionmaker(engine, expire_on_commit=False))
saved = await repository.replace_all(
[
{
"id": "sql-q1",
"question": "SQL 精确问题",
"answer": "SQL 固定回答",
"enabled": True,
"tokens_per_second": 120,
"sort_order": 0,
}
],
updated_by="admin",
)
assert saved[0]["updated_by"] == "admin"
assert saved[0]["tokens_per_second"] == 120
assert (await repository.find_enabled("SQL 精确问题"))["answer"] == "SQL 固定回答"
assert await repository.find_enabled("sql 精确问题") is None
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_lookup_skips_attachments_and_internal_runs() -> None:
store = MemoryFixedQuestionStore()
await store.replace_all(
[
{
"id": "q1",
"question": "固定问题",
"answer": "固定回答",
"enabled": True,
"sort_order": 0,
}
]
)
request = _request(store)
plain_input = normalize_input({"messages": [{"content": "固定问题"}]})
match = await _find_fixed_answer(
body=_body(),
request=request,
graph_input=plain_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
assert match is not None
assert match["id"] == "q1"
attachment_input = normalize_input(
{
"messages": [
{
"content": "固定问题",
"additional_kwargs": {"files": [{"name": "context.pdf"}]},
}
]
}
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=attachment_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
is None
)
contextual_input = normalize_input(
{
"messages": [
{
"content": "固定问题",
"additional_kwargs": {"prompt_prefix": "网页正文上下文"},
}
]
}
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=contextual_input,
run_scope=RUN_SCOPE_EXTERNAL_ROOT,
)
is None
)
assert (
await _find_fixed_answer(
body=_body(),
request=request,
graph_input=plain_input,
run_scope=RUN_SCOPE_ORCHESTRATION_CHILD,
)
is None
)
@pytest.mark.asyncio
async def test_fixed_answer_agent_streams_chunks_and_persists_full_answer() -> None:
question = "固定问题"
answer = "这是一个足够长的固定回答,用于验证返回内容会拆成多个流式消息块,同时完整写入会话状态。"
agent = make_fixed_answer_agent(
question=question,
answer=answer,
fixed_question_id="q1",
tokens_per_second=1000,
)
chunks: list[str] = []
final_state = None
async for mode, data in agent.astream(
{"messages": [HumanMessage(content=question)]},
stream_mode=["values", "messages"],
):
if mode == "messages":
message, _metadata = data
if isinstance(message.content, str):
chunks.append(message.content)
elif mode == "values":
final_state = data
assert "".join(chunks) == answer
assert len([chunk for chunk in chunks if chunk]) >= 2
assert final_state is not None
assert final_state["messages"][-1].content == answer
assert final_state["messages"][-1].additional_kwargs["fixed_answer"] is True
assert final_state["messages"][-1].additional_kwargs["tokens_per_second"] == 1000
assert final_state["title"] == question
assert final_state["title_provisional"] is False
@pytest.mark.asyncio
async def test_fixed_answer_default_speed_schedules_100_tokens_per_second(
monkeypatch: pytest.MonkeyPatch,
) -> None:
answer = "one two three four five six seven eight nine ten eleven twelve thirteen"
sleeps: list[float] = []
async def record_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr(fixed_answer_module.asyncio, "sleep", record_sleep)
model = fixed_answer_module.FixedAnswerChatModel(
answer=answer,
message_id="speed-test",
fixed_question_id="q-speed",
)
chunks: list[str] = []
async for generation in model._astream([]):
chunks.append(str(generation.message.content))
token_count = len(fixed_answer_module._TOKEN_ENCODING.encode(answer))
assert "".join(chunks) == answer
assert model.tokens_per_second == 100
assert sum(sleeps) == pytest.approx((token_count - 1) / 100)
def test_replace_request_rejects_duplicate_exact_questions() -> None:
row = {
"question": "重复问题",
"answer": "回答",
"enabled": True,
"tokens_per_second": 100,
"sort_order": 0,
}
with pytest.raises(ValidationError):
FixedQuestionReplaceRequest(
questions=[
FixedQuestion(id="q1", **row),
FixedQuestion(id="q2", **row),
]
)
with pytest.raises(ValidationError):
FixedQuestionReplaceRequest(
questions=[
FixedQuestion(id="same-id", **row),
FixedQuestion(
id="same-id",
question="另一个问题",
answer="另一个回答",
enabled=True,
sort_order=1,
),
]
)
def test_fixed_question_stream_speed_defaults_and_bounds() -> None:
default_row = FixedQuestion(
id="default-speed",
question="默认速度",
answer="回答",
)
assert default_row.tokens_per_second == 100
for invalid_speed in (0, 1001):
with pytest.raises(ValidationError):
FixedQuestion(
id=f"invalid-{invalid_speed}",
question="非法速度",
answer="回答",
tokens_per_second=invalid_speed,
)
@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.gateway import auth_middleware from app.gateway import auth_middleware
from app.gateway.app import app as gateway_app from app.gateway.app import app as gateway_app
from app.gateway.auth.token_source import get_explicit_or_session_token
from app.gateway.auth_middleware import PUBLIC_HEALTH_PATHS, AuthMiddleware from app.gateway.auth_middleware import PUBLIC_HEALTH_PATHS, AuthMiddleware
@@ -98,3 +101,28 @@ def test_non_health_route_still_requires_auth(monkeypatch: pytest.MonkeyPatch) -
response = client.get("/private") response = client.get("/private")
assert response.status_code == 401 assert response.status_code == 401
def _auth_request(*, authorization: str = "", cookie: str = "") -> SimpleNamespace:
headers = {"authorization": authorization} if authorization else {}
cookies = {"access_token": cookie} if cookie else {}
return SimpleNamespace(headers=headers, cookies=cookies)
def test_explicit_bearer_token_wins_over_shared_cookie() -> None:
"""Another tab's cookie must not override this tab's explicit token."""
request = _auth_request(authorization="Bearer current-tab", cookie="other-tab")
assert get_explicit_or_session_token(request) == "current-tab"
def test_cookie_remains_the_fallback_for_cookie_only_clients() -> None:
request = _auth_request(cookie="browser-session")
assert get_explicit_or_session_token(request) == "browser-session"
def test_malformed_authorization_header_does_not_hide_valid_cookie() -> None:
request = _auth_request(authorization="Basic abc", cookie="browser-session")
assert get_explicit_or_session_token(request) == "browser-session"
@@ -9,15 +9,18 @@ Verifies that:
from __future__ import annotations from __future__ import annotations
import os
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import UUID, uuid4 from uuid import uuid4
import pytest import pytest
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.models import User from app.gateway.auth.models import User
from app.gateway.auth.repositories.base import UserRepository from app.gateway.auth.repositories.base import UserRepository
from app.gateway.routers import auth as auth_module
from app.gateway.routers.auth import ( from app.gateway.routers.auth import (
_get_or_create_user_by_username,
_lookup_user_by_username,
_normalize_username_local_part, _normalize_username_local_part,
_username_to_email, _username_to_email,
) )
@@ -74,6 +77,9 @@ class FakeUserRepository(UserRepository):
async def count_admin_users(self) -> int: async def count_admin_users(self) -> int:
return sum(1 for u in self.rows.values() if u.system_role == "admin") return sum(1 for u in self.rows.values() if u.system_role == "admin")
async def list_users(self) -> list[User]:
return sorted(self.rows.values(), key=lambda user: user.created_at)
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
return None return None
@@ -146,3 +152,38 @@ async def test_local_part_lookup_misses_unknown_username() -> None:
repo = FakeUserRepository() repo = FakeUserRepository()
_seed(repo, "alice@cm.com") _seed(repo, "alice@cm.com")
assert await repo.find_user_by_email_local_part("bob") is None assert await repo.find_user_by_email_local_part("bob") is None
@pytest.mark.asyncio
async def test_username_login_uses_oldest_account_when_canonical_duplicate_exists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A domain migration must not make one username alternate user ids."""
repo = FakeUserRepository()
older = _seed(repo, "alice@local.deerflow")
older.created_at = datetime(2024, 1, 1, tzinfo=UTC)
newer = _seed(repo, "alice@cm.com")
newer.created_at = datetime(2026, 1, 1, tzinfo=UTC)
monkeypatch.setattr(auth_module, "get_local_provider", lambda: LocalAuthProvider(repo))
user, created = await _get_or_create_user_by_username("alice")
assert created is False
assert user.id == older.id
@pytest.mark.asyncio
async def test_password_lookup_uses_same_stable_username_account(
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = FakeUserRepository()
older = _seed(repo, "alice@local.deerflow")
older.created_at = datetime(2024, 1, 1, tzinfo=UTC)
newer = _seed(repo, "alice@cm.com")
newer.created_at = datetime(2026, 1, 1, tzinfo=UTC)
monkeypatch.setattr(auth_module, "get_local_provider", lambda: LocalAuthProvider(repo))
user = await _lookup_user_by_username("alice")
assert user is not None
assert user.id == older.id