feat: add fixed questions and improve authentication
This commit is contained in:
1 parent
8af4fed89d
commit
4c9f75f31e
42 files changed
+4008
-200
No files matched your search
+11
-10
@@ -102,16 +102,17 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
<AuthSyncWatcher disabled={positionStandaloneMode}>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</AuthSyncWatcher>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
ListTree,
|
||||
MessageSquareText,
|
||||
MousePointerClick,
|
||||
BriefcaseBusiness,
|
||||
Settings,
|
||||
@@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/fixed-questions")}
|
||||
>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/position-roles")}
|
||||
@@ -558,6 +567,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/fixed-questions")}>
|
||||
<MessageSquareText className="size-4" />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
|
||||
<BriefcaseBusiness className="size-4" />
|
||||
|
||||
@@ -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({
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={selected.has(item.id)}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={() => 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({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`}
|
||||
</span>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -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 (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
title={value.length > 0 ? selectedLabel : undefined}
|
||||
>
|
||||
{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 && (
|
||||
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
|
||||
{value.length}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
MessageSquareTextIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
@@ -268,6 +269,16 @@ export function WorkspaceNavMenu() {
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/fixed-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
|
||||
@@ -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<string, unknown> | 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}`;
|
||||
|
||||
@@ -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<string | null>(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 ? <Fragment key={revision}>{children}</Fragment> : null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -775,6 +775,17 @@ export default function AgentChatPage({
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
knowledgeSelector={
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="agent"
|
||||
agentId={agent_id}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
readOnly
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
|
||||
@@ -837,20 +837,19 @@ export default function ChatPage() {
|
||||
: undefined
|
||||
}
|
||||
knowledgeSelector={
|
||||
isLandingState ? (
|
||||
<LlmWikiKnowledgeSelector
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={!!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
knowledgeSelectionLoading
|
||||
}
|
||||
label="知识空间"
|
||||
/>
|
||||
) : undefined
|
||||
<LlmWikiKnowledgeSelector
|
||||
source={selectedAgentId ? "agent" : "all"}
|
||||
agentId={selectedAgentId}
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={changeKnowledgeSelection}
|
||||
autoSelectAll={isLandingState && !!selectedAgentId}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
knowledgeSelectionLoading
|
||||
}
|
||||
readOnly={!isLandingState}
|
||||
label="知识空间"
|
||||
/>
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import FixedQuestionsPage from "./FixedQuestionsPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
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
|
||||
path="admin/sensitive-words"
|
||||
element={
|
||||
|
||||
@@ -453,6 +453,11 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// ask_clarification 是本轮的严格暂停点。某些模型在工具调用已经产出
|
||||
// 选项卡后还会继续吐一段“请做选择”等正文;从第一张卡出现起忽略后续
|
||||
// 文本 delta,但继续消费流以收齐同轮的其它澄清卡和终态帧。
|
||||
let clarificationSeen = false;
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
@@ -463,6 +468,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
if (clarificationSeen) return;
|
||||
setInteractiveMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === agentMsgId
|
||||
@@ -505,6 +511,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
);
|
||||
},
|
||||
onClarification: (clarification, index) => {
|
||||
clarificationSeen = true;
|
||||
// 实时把每条澄清问题灌进气泡 —— 用户能边流边看到「协助过程」逐个出现,
|
||||
// 而不是等终态帧(终态帧只带第 1 个问题)。同时往步骤链补一条
|
||||
// 「追问 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);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user