From 4c9f75f31e38f15723ed20a2ff5dfe0cfeed3514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 21 Aug 2026 17:58:39 +0800 Subject: [PATCH 1/5] feat: add fixed questions and improve authentication --- .gitignore | 2 + frontend-web/src/App.tsx | 21 +- frontend-web/src/components/page-sidebar.tsx | 15 + .../workspace/llmwiki-knowledge-selector.tsx | 47 +- .../workspace/workspace-nav-menu.tsx | 11 + frontend-web/src/core/auth/index.ts | 29 +- frontend-web/src/core/auth/sync.tsx | 76 +- .../src/core/auth/use-live-account.ts | 6 +- frontend-web/src/core/threads/hooks.ts | 33 +- .../src/core/threads/thread-access-error.ts | 24 +- .../open-canvas/contexts/AIWritingContext.tsx | 30 +- frontend-web/src/pages/AgentChatPage.tsx | 11 + frontend-web/src/pages/ChatPage.tsx | 27 +- frontend-web/src/pages/FixedQuestionsPage.tsx | 310 +++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../hooks/useStep1Intent.ts | 7 + .../api/fixed-questions.ts | 122 + offline-backend-20260512/backend/CLAUDE.md | 3 + offline-backend-20260512/backend/README.md | 19 + .../backend/app/gateway/app.py | 8 +- .../backend/app/gateway/auth/token_source.py | 30 + .../backend/app/gateway/deps.py | 56 +- .../backend/app/gateway/langgraph_auth.py | 16 +- .../position-roundtable-intent/SOUL.md | 6 +- .../roundtable-intent/SOUL.md | 6 +- .../backend/app/gateway/routers/auth.py | 60 +- .../app/gateway/routers/fixed_questions.py | 92 + .../backend/app/gateway/routers/intent.py | 25 +- .../backend/app/gateway/services.py | 129 +- .../LLMWIKI_DEERFLOW_LOCAL_VECTOR_INDEX_ZH.md | 2095 +++++++++++++++++ .../deerflow/agents/fixed_answer_agent.py | 186 ++ .../persistence/fixed_questions/__init__.py | 22 + .../persistence/fixed_questions/base.py | 30 + .../persistence/fixed_questions/memory.py | 56 + .../persistence/fixed_questions/model.py | 41 + .../persistence/fixed_questions/sql.py | 88 + .../versions/20260821_01_fixed_questions.py | 51 + ...20260821_02_fixed_question_stream_speed.py | 44 + .../deerflow/persistence/models/__init__.py | 2 + .../backend/tests/test_fixed_questions.py | 288 +++ .../backend/tests/test_health_auth_bypass.py | 28 + .../tests/test_username_login_domain.py | 45 +- 42 files changed, 4008 insertions(+), 200 deletions(-) create mode 100644 frontend-web/src/pages/FixedQuestionsPage.tsx create mode 100644 frontend-web/src/strategy-components/api/fixed-questions.ts create mode 100644 offline-backend-20260512/backend/app/gateway/auth/token_source.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/fixed_questions.py create mode 100644 offline-backend-20260512/backend/docs/LLMWIKI_DEERFLOW_LOCAL_VECTOR_INDEX_ZH.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/fixed_answer_agent.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/fixed_questions/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/fixed_questions/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/fixed_questions/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/fixed_questions/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/fixed_questions/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260821_01_fixed_questions.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260821_02_fixed_question_stream_speed.py create mode 100644 offline-backend-20260512/backend/tests/test_fixed_questions.py diff --git a/.gitignore b/.gitignore index b22d5f03..32e39254 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,8 @@ offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !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 .history diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 2833dea3..0e684bf7 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -102,16 +102,17 @@ function AppProviders({ children }: { children: React.ReactNode }) { forcedTheme={forcedTheme} > - - - - {!positionStandaloneMode && } - {!positionStandaloneMode && } - - {children} - - - + + + + + {!positionStandaloneMode && } + + {children} + + + + ); diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 5119a2ba..dd85aeeb 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -10,6 +10,7 @@ import { FolderOpen, Gauge, ListTree, + MessageSquareText, MousePointerClick, BriefcaseBusiness, Settings, @@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/fixed-questions")} + > + + 问题配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")} @@ -558,6 +567,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/fixed-questions")}> + + 问题配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")}> diff --git a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx index b1d0333b..afdbd6f3 100644 --- a/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx +++ b/frontend-web/src/components/workspace/llmwiki-knowledge-selector.tsx @@ -76,6 +76,7 @@ function SelectionContent({ error, source, variant, + readOnly, }: { rows: LlmWikiKnowledgeBase[]; value: string[]; @@ -86,6 +87,7 @@ function SelectionContent({ error: unknown; source: SelectorSource; variant: SelectorVariant; + readOnly: boolean; }) { const selected = useMemo(() => new Set(value), [value]); const filtered = useMemo(() => { @@ -97,6 +99,7 @@ function SelectionContent({ }, [rows, search]); const groups = useMemo(() => groupRows(filtered), [filtered]); const toggle = (id: string) => { + if (readOnly) return; onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]); }; @@ -168,6 +171,7 @@ function SelectionContent({ toggle(item.id)} onSelect={(event) => event.preventDefault()} className={cn( @@ -183,6 +187,7 @@ function SelectionContent({ type="button" role="checkbox" aria-checked={selected.has(item.id)} + disabled={readOnly} onClick={() => toggle(item.id)} 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", @@ -205,15 +210,21 @@ function SelectionContent({
- 已选择 {value.length} 个 -
- - -
+ + {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`} + + {readOnly ? ( + 对话开始后不可更改 + ) : ( +
+ + +
+ )}
); @@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({ agentId, autoSelectAll = false, disabled = false, + readOnly = false, className, label = "知识空间", variant = "popover", @@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({ agentId?: string | null; autoSelectAll?: boolean; disabled?: boolean; + /** Keep the selected knowledge spaces visible without allowing the thread scope to change. */ + readOnly?: boolean; className?: string; label?: string; variant?: SelectorVariant; @@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({ error={error} source={source} variant={variant} + readOnly={readOnly} /> ); @@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({ ); } - const selectedName = value.length === 1 - ? rows.find((item) => item.id === value[0])?.name - : null; + const selectedNames = value + .map((id) => rows.find((item) => item.id === id)?.name) + .filter((name): name is string => !!name); + const selectedLabel = selectedNames.length > 0 + ? selectedNames.join("、") + : value.length > 0 + ? `已选 ${value.length} 个知识空间` + : label; return ( @@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({ className, )} disabled={disabled || loading} + title={value.length > 0 ? selectedLabel : undefined} > {loading ? : } - {selectedName || label} + {selectedLabel} {value.length > 0 && ( {value.length} diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 25865950..e15573ad 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -11,6 +11,7 @@ import { GaugeIcon, HistoryIcon, MessageCircleQuestionIcon, + MessageSquareTextIcon, ScrollTextIcon, Settings2Icon, SettingsIcon, @@ -268,6 +269,16 @@ export function WorkspaceNavMenu() { 推荐问题配置
)} + {isAdmin && ( + + navigate("/page/strategy/admin/fixed-questions") + } + > + + 问题配置 + + )} {isAdmin && logSystem?.enabled && logSystem.url && ( diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 36eb5542..64ad9bad 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string { 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 { let info: Record | null = null; try { @@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null { /** * 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 - * to the same business account. Prefer business-facing identifiers - * (email/username/userInfo) and keep ``user_id`` only as the final fallback. + * Some external login paths can re-issue a backend ``user_id`` or use a + * different synthetic email domain while resolving to the same business + * 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( auth: LoginByUsernameResponse | null = getStoredAuth(), ): string | null { if (!auth) return null; - const email = normalizeIdentityPart(auth.email); - if (email) return `email:${email}`; + const loginIdentity = identityFromLoginName(auth.username); + if (loginIdentity) return loginIdentity; - const username = normalizeIdentityPart(auth.username); - if (username) return `username:${username}`; + const email = normalizeIdentityPart(auth.email); + if (email) { + const localPart = email.split("@", 1)[0]; + return localPart ? `account:${localPart}` : `email:${email}`; + } const userInfoIdentity = readUserInfoIdentity(); if (userInfoIdentity) return `userInfo:${userInfoIdentity}`; diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index 65321bd3..b4fc5c95 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -1,9 +1,15 @@ "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 { getAccountIdentityKey } from "@/core/auth"; +import { + AUTH_STATE_CHANGED_EVENT, + getAccountDisplayName, + getAccountIdentityKey, +} from "@/core/auth"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; @@ -14,14 +20,37 @@ function readIdentityKey(): string | null { 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 - * tabs and lets the user decide when to refresh on account switches. + * Cross-tab auth boundary for normal pages. + * + * 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(readIdentityKey()); + const [revision, setRevision] = useState(0); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { + if (disabled) return; // 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页 // 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。 if (isEmbedActive() || isIsolatedSession()) return; @@ -36,18 +65,27 @@ export function AuthSyncWatcher() { lastIdentityRef.current = nextIdentity; + void queryClient.cancelQueries(); + queryClient.clear(); + window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT)); + setRevision((value) => value + 1); + if (nextIdentity === null) { + toast.info("账号已在其它页面退出", { + id: IDENTITY_TOAST_ID, + description: "当前页面已同步退出状态。", + }); + navigate("/login", { replace: true }); return; } - toast.warning("检测到账号在其它标签页发生变化", { + const freshRoute = freshRouteForAccount(location.pathname); + if (freshRoute !== location.pathname) { + navigate(freshRoute, { replace: true }); + } + toast.info("账号已在页面间自动同步", { id: IDENTITY_TOAST_ID, - description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。", - duration: Infinity, - action: { - label: "刷新同步", - onClick: () => window.location.reload(), - }, + description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`, }); } @@ -68,14 +106,24 @@ export function AuthSyncWatcher() { 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(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange); return () => { window.removeEventListener("storage", handleStorage); + window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange); if (settleTimer) { clearTimeout(settleTimer); } }; - }, []); + }, [disabled, location.pathname, navigate, queryClient]); - return null; + return children ? {children} : null; } diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts index 8cfedd8c..b855973f 100644 --- a/frontend-web/src/core/auth/use-live-account.ts +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -8,7 +8,7 @@ import { getAccountIdentityKey, getStoredAuth, } from "@/core/auth"; -import { isEmbedActive } from "@/core/embed/embed-session"; +import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; @@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount { 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(AUTH_STATE_CHANGED_EVENT, refresh); diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 5c235bb1..7d26057e 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -696,17 +696,15 @@ export function useThreadStream({ } return; } - // A "thread not found" 404 almost always means the URL points at a - // thread from a previous login session — either this tab outlived a - // logout/re-login or the thread genuinely belongs to a different user. - // Re-frame the error around the *current* logged-in identity and bounce - // the user to a fresh chat instead of leaving them stuck on the stale - // route looking at a raw HTTP 404. - if (isThreadNotAccessibleError(message)) { - const userLabel = buildCurrentUserLabel(); - toast.info( - `当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`, - ); + // A "thread not found" 404 means this route cannot accept another turn. + // Move to a fresh chat without a full browser reload; the old reload path + // made every click look like a crash and could loop indefinitely when a + // different tab's cookie kept overriding this tab's Bearer token. + if (isThreadNotAccessibleError(message)) { + const userLabel = buildCurrentUserLabel(); + toast.info( + `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, + ); // Prefer staying inside an agent chat context when the user is on an // agent thread URL — otherwise fall back to the global new chat. const hash = typeof window !== "undefined" ? window.location.hash : ""; @@ -717,18 +715,7 @@ export function useThreadStream({ : iframeMatch ? "/page/workspace/chats_iframe/new" : "/page/workspace/chats/new"; - // First navigate to a safe URL so the post-reload page lands on a - // 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 }); - } + navigate(target, { replace: true }); return; } // 上一轮还没结束又发了新问题(409)或并发达到上限(429): diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts index dd803081..68ec23dd 100644 --- a/frontend-web/src/core/threads/thread-access-error.ts +++ b/frontend-web/src/core/threads/thread-access-error.ts @@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth"; * 404 just leaves the user staring at a scary error. Both the chat stream * (``core/threads/hooks.ts``) and the AI-writing stream * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors - * through here so the UX is identical: detect the login-change 404 and prompt - * the user to refresh against the current identity instead of erroring out. + * through here so the UX is identical: detect the login-change 404 and reset + * 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. */ @@ -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 - * short delay so the toast is visible and so TanStack Query cache / context - * 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. + * Streams whose session id lives in component state pass a local reset callback + * so they can discard the inaccessible session without a browser reload. */ -export function notifyThreadAccessChangedAndReload(): void { +export function notifyThreadAccessChanged(onReset: () => void): void { const userLabel = buildCurrentUserLabel(); toast.info( - `当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`, + `当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`, ); - if (typeof window !== "undefined") { - window.setTimeout(() => { - window.location.reload(); - }, 900); - } + onReset(); } diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index 2b0b9f0a..de4e300f 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -18,7 +18,7 @@ import { getAPIClient } from '@/core/api'; import { getStreamErrorMessage, isThreadNotAccessibleError, - notifyThreadAccessChangedAndReload, + notifyThreadAccessChanged, } from '@/core/threads/thread-access-error'; 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 // (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 - // the panel, re-frame it around the current identity and reload — same UX - // the chat stream gives (see core/threads/hooks.ts). + // the panel, re-frame it around the current identity and discard the + // stale component-state session without reloading the browser page. if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(() => { + continueTargetRef.current = null; + dispatchRef.current?.({ type: 'reset' }); + }); return; } dispatchRef.current?.({ type: 'set_error', message }); @@ -410,6 +413,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { // (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。 const threadId = state.sessionId ?? undefined; + const resetAfterThreadAccessError = useCallback(() => { + setArtifact(null); + seenInterruptRef.current = null; + continueTargetRef.current = null; + dispatch({ type: 'reset' }); + }, [setArtifact]); + useEffect(() => { const s = state.status; const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_'); @@ -473,12 +483,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } - }, [state.status]); + }, [state.status, resetAfterThreadAccessError]); const submitIntervention = useCallback(async (payload: UserInterventionPayload) => { if (!state.sessionId || !state.currentPause) return; @@ -585,14 +595,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } }, [state.sessionId, state.currentPause, state.materials, state.outline, state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, - state.status]); + state.status, resetAfterThreadAccessError]); const pauseWriting = useCallback(() => { // 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。 @@ -626,13 +636,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } })(); - }, [state.request]); + }, [state.request, resetAfterThreadAccessError]); // 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. diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 0d9d57a3..6339b226 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -775,6 +775,17 @@ export default function AgentChatPage({ onContextChange={(context) => setSettings("context", context) } + knowledgeSelector={ + + } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 08c09d7b..c60da639 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -837,20 +837,19 @@ export default function ChatPage() { : undefined } knowledgeSelector={ - isLandingState ? ( - - ) : undefined + } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} diff --git a/frontend-web/src/pages/FixedQuestionsPage.tsx b/frontend-web/src/pages/FixedQuestionsPage.tsx new file mode 100644 index 00000000..98e276da --- /dev/null +++ b/frontend-web/src/pages/FixedQuestionsPage.tsx @@ -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([]); + const [loadedKey, setLoadedKey] = useState(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 ; + } + + const patchRow = (id: string, patch: Partial) => { + 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(); + 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 ( +
+
+
+
+
+
+ +
+
+
+

问题配置

+ + {rows.length} 条 / {enabledCount} 条启用 + +
+

+ 为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。 +

+
+
+
+ + +
+
+
+ + + 匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。 + +
+
+ +
+
+ + setQuery(event.target.value)} + placeholder="搜索问题或回答…" + className="pl-9" + /> +
+
+ + 启用项保存后立即生效 +
+
+ + {isLoading ? ( +
+ 加载中… +
+ ) : visibleRows.length === 0 ? ( +
+ {rows.length === 0 + ? "暂无问题配置,点击「新增问题」开始配置" + : "没有匹配的问题或回答"} +
+ ) : ( +
+ {visibleRows.map((row, index) => ( +
+
+
+ + {index + 1} + + 固定问答 +
+
+ + + +
+
+
+