页面嵌套修改
This commit is contained in:
1 parent
213dd841b5
commit
4450f342d8
38 files changed
+2020
-309
No files matched your search
@@ -2,14 +2,12 @@
|
||||
|
||||
import {
|
||||
BookMarkedIcon,
|
||||
BotIcon,
|
||||
BrainCircuitIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
ShrinkIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useAgentSelector } from "@/core/agents";
|
||||
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
|
||||
import { getAccessToken } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type BrainyChatSubmitMessage,
|
||||
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
|
||||
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
|
||||
useState<PendingIframeSubmit | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
useAgentSelector();
|
||||
// The protocol page must still be escapable when no curated selector list
|
||||
// has been configured. Include the user's own and public agents in this
|
||||
// page's native fallback picker. A native select also remains interactive
|
||||
// in iframe/micro-frontend environments where a portal dialog can fail.
|
||||
const { agents: mineAgents } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const { agents: squareAgents } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
const fallbackAgents = useMemo(() => {
|
||||
const uniqueAgents = new Map<
|
||||
string,
|
||||
(typeof selectorAgents)[number]
|
||||
>();
|
||||
for (const agent of [
|
||||
...selectorAgents,
|
||||
...mineAgents,
|
||||
...squareAgents,
|
||||
]) {
|
||||
uniqueAgents.set(agent.id, agent);
|
||||
}
|
||||
return [...uniqueAgents.values()];
|
||||
}, [mineAgents, selectorAgents, squareAgents]);
|
||||
const { data: pluginManagementSettings } = usePluginManagementSettings();
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Manual fallback for accidental protocol-page visits.
|
||||
*
|
||||
* A real Brainy integration drives this page with postMessage. A human who
|
||||
* reaches it directly should not be trapped here: move them to the standard
|
||||
* chat workspace, optionally opening the selected agent.
|
||||
*/
|
||||
const enterStandardChat = useCallback(
|
||||
(agentId?: string) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("from");
|
||||
params.set("chatMode", "full");
|
||||
params.delete("agent");
|
||||
if (agentId) {
|
||||
params.set("agent", agentId);
|
||||
saveLastAgentId(agentId);
|
||||
}
|
||||
navigate(`${routeBase}/chats/new?${params.toString()}`, {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[location.search, navigate, routeBase],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
{isLandingState && mounted && (
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-6 flex min-h-40 flex-col items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
选择智能体进入问答
|
||||
<div className="mt-4">
|
||||
<AgentSelector
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={handleAgentChange}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
|
||||
<BotIcon className="mr-2 size-3.5 shrink-0" />
|
||||
<select
|
||||
aria-label="选择智能体并进入问答"
|
||||
defaultValue=""
|
||||
disabled={isUploading || selectorLoading}
|
||||
onChange={(event) => {
|
||||
const agentId = event.target.value;
|
||||
if (agentId) enterStandardChat(agentId);
|
||||
}}
|
||||
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
|
||||
</option>
|
||||
{fallbackAgents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{desensitize(agent.name)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute right-3 text-[10px]"
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{!selectorLoading && fallbackAgents.length === 0 && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
暂无可选智能体
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
|
||||
import {
|
||||
getRouteKeyLoginPassword,
|
||||
resolveRouteKey,
|
||||
resolveRouteKeyMapping,
|
||||
ROUTE_KEY_PARAM,
|
||||
} from '@/core/auth/route-key-login';
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from '@/core/auth/task-deeplink';
|
||||
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
|
||||
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
import { readEmbedParam } from '@/core/embed/embed-session';
|
||||
import {
|
||||
GENERAL_QA_ROUTE,
|
||||
@@ -33,9 +35,14 @@ import {
|
||||
persistToolsToken,
|
||||
} from '@/core/auth/yuser-login';
|
||||
import {
|
||||
buildStandardChatRedirectPath,
|
||||
buildEmbeddedIframeRedirectPath,
|
||||
buildChatsIframeRedirectPath,
|
||||
hasIframePresentationOverride,
|
||||
isChatsIframeLogin,
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolvePostLoginRedirectPath,
|
||||
shouldUseChatsIframeProtocol,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import {
|
||||
isSidebarEnabledFromSearch,
|
||||
@@ -101,22 +108,42 @@ export default function LoginPage() {
|
||||
);
|
||||
const requestedChatMode =
|
||||
searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
|
||||
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE;
|
||||
// Wujie is an application container, not the Brainy iframe protocol. It
|
||||
// must therefore land on the regular full ChatPage. Plugin links with
|
||||
// `from=iframe` are normalized into the explicit iframe protocol mode.
|
||||
const iframePresentationOverride = useMemo(
|
||||
() => hasIframePresentationOverride(searchParams.toString()),
|
||||
[searchParams],
|
||||
);
|
||||
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
|
||||
// `from=iframe` is the established entry contract for the dedicated iframe
|
||||
// page. A standard presentation flag is the one intentional exception: it
|
||||
// uses the normal full chat workspace so Header/PageSidebar visibility is
|
||||
// controlled by hideHeader/hideChrome/embed instead.
|
||||
const brainyIframeRequested = shouldUseChatsIframeProtocol(
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
// An explicit full-chat request and all standard presentation flags retain
|
||||
// the regular workspace. Otherwise native iframe/Wujie hosts use the
|
||||
// legacy dedicated AgentSelector entry, matching the established bare
|
||||
// iframe login integration.
|
||||
const fullChatRequested = useMemo(
|
||||
() =>
|
||||
!brainyIframeRequested &&
|
||||
(isWujieEnv() ||
|
||||
requestedChatMode === FULL_CHAT_MODE),
|
||||
[brainyIframeRequested, requestedChatMode, searchParams],
|
||||
requestedChatMode === FULL_CHAT_MODE ||
|
||||
// hideHeader/hideChrome/embed are explicit presentation contracts.
|
||||
// They must retain their query parameters and enter the standard
|
||||
// workspace even when a native iframe has no legacy `from` marker.
|
||||
iframePresentationOverride),
|
||||
[
|
||||
brainyIframeRequested,
|
||||
iframePresentationOverride,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
],
|
||||
);
|
||||
const isIframeLogin = useMemo(
|
||||
() =>
|
||||
brainyIframeRequested ||
|
||||
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
|
||||
[brainyIframeRequested, fullChatRequested, searchParams],
|
||||
() => brainyIframeRequested,
|
||||
[brainyIframeRequested],
|
||||
);
|
||||
const resolvedUsername = useMemo(() => {
|
||||
const raw = username?.trim();
|
||||
@@ -127,22 +154,37 @@ export default function LoginPage() {
|
||||
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
|
||||
const redirectPath = useMemo(
|
||||
() => {
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
// Keep the mode on the destination URL so PageRoutes can retain the
|
||||
// full chat workspace sidebar while hideChrome hides only the global
|
||||
// application chrome.
|
||||
if (brainyIframeRequested) {
|
||||
return buildChatsIframeRedirectPath(searchParams.toString());
|
||||
return !isChatsIframeLogin(searchParams.toString()) &&
|
||||
requestedChatMode !== IFRAME_CHAT_MODE
|
||||
? buildEmbeddedIframeRedirectPath(searchParams.toString())
|
||||
: buildChatsIframeRedirectPath(searchParams.toString());
|
||||
}
|
||||
if (fullChatRequested) {
|
||||
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`;
|
||||
return buildStandardChatRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
}
|
||||
// External iframe callers frequently also provide yUserId for login.
|
||||
// That identity parameter must not steal a deliberate iframe entry and
|
||||
// send it to the generic full-chat home before the route split above.
|
||||
if (yUserId) return GENERAL_QA_ROUTE;
|
||||
return resolvePostLoginRedirectPath(
|
||||
searchParams.toString(),
|
||||
DEFAULT_REDIRECT_PATH,
|
||||
);
|
||||
},
|
||||
[brainyIframeRequested, fullChatRequested, searchParams, yUserId],
|
||||
[
|
||||
brainyIframeRequested,
|
||||
fullChatRequested,
|
||||
requestedChatMode,
|
||||
searchParams,
|
||||
yUserId,
|
||||
],
|
||||
);
|
||||
const toolToken = searchParams.get('token')?.trim() ?? '';
|
||||
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
|
||||
@@ -152,6 +194,10 @@ export default function LoginPage() {
|
||||
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
|
||||
const authToken = searchParams.get('authToken')?.trim() ?? '';
|
||||
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
|
||||
const routeKeyMapping = useMemo(
|
||||
() => resolveRouteKeyMapping(routeKey),
|
||||
[routeKey],
|
||||
);
|
||||
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
|
||||
const configuredRouteLoginPassword = useMemo(
|
||||
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
|
||||
@@ -183,6 +229,14 @@ export default function LoginPage() {
|
||||
const normalized = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
const [pathOnly, ownQuery = ''] = normalized.split('?');
|
||||
const target = new URLSearchParams(ownQuery);
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
pathOnly,
|
||||
searchParams.toString(),
|
||||
embeddedHost,
|
||||
);
|
||||
if (iframeEntryTarget) {
|
||||
return iframeEntryTarget;
|
||||
}
|
||||
// Keep the Wujie/full-chat decision on the target URL. The destination
|
||||
// route uses it to retain the full agent/history workspace sidebar.
|
||||
if (
|
||||
@@ -204,7 +258,7 @@ export default function LoginPage() {
|
||||
});
|
||||
const qs = target.toString();
|
||||
return qs ? `${pathOnly}?${qs}` : pathOnly;
|
||||
}, [fullChatRequested, routeKeyTarget, searchParams]);
|
||||
}, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
|
||||
const externalParams = useMemo(
|
||||
() => ({
|
||||
theme: searchParams.get('theme') ?? '',
|
||||
@@ -243,10 +297,20 @@ export default function LoginPage() {
|
||||
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
|
||||
useEffect(() => {
|
||||
if (isEmbed || isIframeLogin) return;
|
||||
const themeValue = wujieProps.theme || externalParams.theme;
|
||||
// routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
|
||||
// `theme=dark-blue` 也能在登录完成前切换到指定主题。
|
||||
const themeValue =
|
||||
routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
|
||||
if (!themeValue) return;
|
||||
setTheme(resolveThemeFromQuery(themeValue));
|
||||
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]);
|
||||
}, [
|
||||
isEmbed,
|
||||
isIframeLogin,
|
||||
routeKeyMapping?.theme,
|
||||
wujieProps.theme,
|
||||
externalParams.theme,
|
||||
setTheme,
|
||||
]);
|
||||
|
||||
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 岗位角色配置(仅管理员)。
|
||||
*
|
||||
* 管理岗位会商使用的“工作视角”而非组织岗位/RBAC:一个启用的意图识别岗
|
||||
* 承担第一阶段意图确认,并承载方案总结、行动规划两个收口智能体入口。
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
BriefcaseBusiness,
|
||||
CircleDot,
|
||||
Info,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type PositionRole,
|
||||
type PositionRoleType,
|
||||
usePositionRoleActions,
|
||||
usePositionRoles,
|
||||
} from "@/strategy-components/api/position-roles";
|
||||
|
||||
let sequence = 0;
|
||||
const newRoleId = () => `custom-${Date.now()}-${++sequence}`;
|
||||
const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
||||
|
||||
function serialize(rows: PositionRole[]): string {
|
||||
return JSON.stringify(
|
||||
rows.map((row, index) => ({
|
||||
...row,
|
||||
sortOrder: index,
|
||||
name: row.name.trim(),
|
||||
description: row.description.trim(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function makeNewRole(sortOrder: number): PositionRole {
|
||||
return {
|
||||
id: newRoleId(),
|
||||
name: "新岗位",
|
||||
description: "请填写该岗位在会商流程中的职责",
|
||||
roleType: "standard",
|
||||
enabled: true,
|
||||
isBuiltin: false,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export default function PositionRolesPage() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: serverRows = [], isLoading, isError, error, refetch } = usePositionRoles();
|
||||
const { save } = usePositionRoleActions();
|
||||
const [rows, setRows] = useState<PositionRole[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
|
||||
const sourceKey = useMemo(() => serialize(serverRows), [serverRows]);
|
||||
useEffect(() => {
|
||||
if (sourceKey !== loadedKey) {
|
||||
setRows(serverRows.map((row, index) => ({ ...row, sortOrder: index })));
|
||||
setLoadedKey(sourceKey);
|
||||
}
|
||||
}, [loadedKey, serverRows, sourceKey]);
|
||||
|
||||
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
|
||||
const enabledIntentId = rows.find((row) => row.enabled && row.roleType === "intent")?.id;
|
||||
const usingFallback = serverRows.some((row) => row.isFallback);
|
||||
const enabledCount = rows.filter((row) => row.enabled).length;
|
||||
const intentRole = rows.find((row) => row.id === enabledIntentId);
|
||||
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
const update = (id: string, patch: Partial<PositionRole>) =>
|
||||
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
|
||||
const changeRoleType = (id: string, roleType: PositionRoleType) => {
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.id === id
|
||||
? { ...row, roleType, enabled: roleType === "intent" ? true : row.enabled }
|
||||
: roleType === "intent" && row.roleType === "intent"
|
||||
? { ...row, roleType: "standard" }
|
||||
: row,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnabled = (id: string, enabled: boolean) => {
|
||||
const target = rows.find((row) => row.id === id);
|
||||
if (!enabled && target?.roleType === "intent" && enabledIntentId === id) {
|
||||
toast.info("请先将另一个岗位设为“意图识别岗”,再停用当前岗位");
|
||||
return;
|
||||
}
|
||||
update(id, { enabled });
|
||||
};
|
||||
|
||||
const move = (id: string, direction: "up" | "down") => {
|
||||
const index = rows.findIndex((row) => row.id === id);
|
||||
const nextIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
setRows(next.map((row, order) => ({ ...row, sortOrder: order })));
|
||||
};
|
||||
|
||||
const remove = (row: PositionRole) => {
|
||||
if (row.isBuiltin) {
|
||||
toast.info("内置岗位为兼容历史业务链而保留;如不再使用可先停用它");
|
||||
return;
|
||||
}
|
||||
if (row.id === enabledIntentId) {
|
||||
toast.info("请先将另一个岗位设为“意图识别岗”后再删除");
|
||||
return;
|
||||
}
|
||||
setRows((current) => current.filter((item) => item.id !== row.id).map((item, index) => ({ ...item, sortOrder: index })));
|
||||
};
|
||||
|
||||
const validate = (): PositionRole[] | null => {
|
||||
if (!rows.length) {
|
||||
toast.error("至少需要保留一个岗位");
|
||||
return null;
|
||||
}
|
||||
const normalized = rows.map((row, sortOrder) => ({
|
||||
...row,
|
||||
id: row.id.trim(),
|
||||
name: row.name.trim(),
|
||||
description: row.description.trim(),
|
||||
sortOrder,
|
||||
}));
|
||||
if (normalized.some((row) => !ID_PATTERN.test(row.id))) {
|
||||
toast.error("岗位标识须以字母开头,只能包含字母、数字、- 或 _");
|
||||
return null;
|
||||
}
|
||||
if (normalized.some((row) => !row.name)) {
|
||||
toast.error("请填写每个岗位的名称");
|
||||
return null;
|
||||
}
|
||||
if (new Set(normalized.map((row) => row.id)).size !== normalized.length) {
|
||||
toast.error("岗位标识不能重复");
|
||||
return null;
|
||||
}
|
||||
if (new Set(normalized.map((row) => row.name.toLocaleLowerCase())).size !== normalized.length) {
|
||||
toast.error("岗位名称不能重复");
|
||||
return null;
|
||||
}
|
||||
if (normalized.filter((row) => row.enabled && row.roleType === "intent").length !== 1) {
|
||||
toast.error("请保留且仅保留一个启用的意图识别岗");
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = validate();
|
||||
if (!payload) return;
|
||||
try {
|
||||
await save.mutateAsync(payload);
|
||||
setRows(payload);
|
||||
toast.success("岗位角色配置已保存,会商页面将按新配置显示");
|
||||
} catch (reason) {
|
||||
toast.error(reason instanceof Error ? reason.message : "保存岗位角色配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<header className="border-border/70 flex flex-wrap items-center justify-between gap-4 border-b bg-gradient-to-r from-[var(--color-main,#0062D9)]/[0.08] via-background to-background px-6 py-5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-main,#0062D9)]/10 text-[var(--color-main,#0062D9)]">
|
||||
<BriefcaseBusiness className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-foreground text-lg font-semibold tracking-tight">岗位角色配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理岗位多智能体会商中的岗位名称、职责、性质、启停与顺序;不影响组织岗位、成员或权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setRows((current) => [...current, makeNewRole(current.length)])}>
|
||||
<Plus className="h-4 w-4" /> 添加岗位
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending || isLoading}>
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? "保存中…" : "保存配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
|
||||
<section className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ListChecks className="size-4 text-[var(--color-main,#0062D9)]" />已启用岗位</div>
|
||||
<div className="mt-2 flex items-baseline gap-1"><strong className="text-2xl font-semibold text-foreground">{enabledCount}</strong><span className="text-sm text-muted-foreground">个</span></div>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--color-main,#0062D9)]/30 bg-[var(--color-main,#0062D9)]/[0.05] px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[var(--color-main,#0062D9)]"><CircleDot className="size-4" />会商入口岗位</div>
|
||||
<div className="mt-2 truncate text-base font-semibold text-foreground">{intentRole?.name ?? "尚未配置"}</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">意图识别与收口智能体入口</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ShieldCheck className="size-4 text-emerald-600 dark:text-emerald-400" />内置岗位</div>
|
||||
<div className="mt-2 text-base font-semibold text-foreground">{rows.filter((row) => row.isBuiltin).length} 个稳定席位</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">可改名、调整性质或停用,不可删除</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{usingFallback && (
|
||||
<section className="flex items-start justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
|
||||
<div className="flex gap-2.5 text-amber-900 dark:text-amber-200">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
<p>当前显示的是内置岗位兜底数据。后端接口尚未返回目录;待后端升级并启动后,系统会自动把这四个岗位写入数据库。</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void refetch()}>重新连接</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="border-border/70 bg-muted/35 flex gap-3 rounded-md border px-4 py-3 text-sm">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-main,#0062D9)]" />
|
||||
<div className="text-muted-foreground leading-6">
|
||||
<span className="text-foreground font-medium">意图识别岗</span>
|
||||
是会商入口:它负责任务意图识别,并显示“方案总结”“行动规划”两个收口智能体。系统必须保留且仅保留一个启用的意图识别岗。
|
||||
内置岗位的稳定标识用于兼容历史业务链,不能删除,但可改名、调整性质或停用。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed py-14 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> 正在加载岗位角色配置…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed py-14 text-center text-sm">
|
||||
<p className="text-destructive">{error instanceof Error ? error.message : "岗位角色配置加载失败"}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>重新加载</Button>
|
||||
</div>
|
||||
) : (
|
||||
<section className="rounded-md border border-border/70 bg-card p-4">
|
||||
<div className="mb-4 flex flex-wrap items-end justify-between gap-2 border-b border-border/70 pb-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground"><Bot className="size-4 text-[var(--color-main,#0062D9)]" />岗位目录</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">拖动前可先用上下箭头调整顺序;保存后会立即影响岗位会商的选择与流程。</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">共 {rows.length} 个岗位</span>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{rows.map((row, index) => (
|
||||
<article key={row.id} className={cn("grid grid-cols-[2.5rem_minmax(0,1fr)_11rem_8rem_auto] items-start gap-4 rounded-md border p-4 transition-colors max-lg:grid-cols-[2.5rem_minmax(0,1fr)_11rem] max-md:grid-cols-1 max-md:gap-3", row.roleType === "intent" ? "border-[var(--color-main,#0062D9)]/45 bg-[var(--color-main,#0062D9)]/[0.045]" : "border-border/70 bg-background hover:border-[var(--color-main,#0062D9)]/30")}>
|
||||
<div className={cn("flex size-10 items-center justify-center rounded-md", row.roleType === "intent" ? "bg-[var(--color-main,#0062D9)]/15 text-[var(--color-main,#0062D9)]" : "bg-muted text-muted-foreground")}>
|
||||
{row.roleType === "intent" ? <CircleDot className="size-5" /> : <BriefcaseBusiness className="size-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`role-name-${row.id}`}>岗位名称</label>
|
||||
<Input
|
||||
id={`role-name-${row.id}`}
|
||||
value={row.name}
|
||||
onChange={(event) => update(row.id, { name: event.target.value })}
|
||||
maxLength={128}
|
||||
className="font-medium"
|
||||
placeholder="岗位名称"
|
||||
/>
|
||||
{row.isBuiltin && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">内置</span>}
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={`role-description-${row.id}`}>岗位职责说明</label>
|
||||
<Input
|
||||
id={`role-description-${row.id}`}
|
||||
value={row.description}
|
||||
onChange={(event) => update(row.id, { description: event.target.value })}
|
||||
maxLength={512}
|
||||
className="text-sm"
|
||||
placeholder="该岗位在会商流程中的职责说明"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="shrink-0">岗位标识:</span>
|
||||
{row.isBuiltin ? (
|
||||
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px]">{row.id}</code>
|
||||
) : (
|
||||
<Input
|
||||
aria-label={`${row.name || "新岗位"}的岗位标识`}
|
||||
value={row.id}
|
||||
onChange={(event) => update(row.id, { id: event.target.value })}
|
||||
maxLength={128}
|
||||
className="h-7 max-w-xs font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)] max-md:items-center">
|
||||
<span className="text-xs text-muted-foreground md:hidden">岗位性质</span>
|
||||
<Select value={row.roleType} onValueChange={(value) => changeRoleType(row.id, value as PositionRoleType)}>
|
||||
<SelectTrigger aria-label={`${row.name || "岗位"}的岗位性质`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">普通执行岗</SelectItem>
|
||||
<SelectItem value="intent">意图识别岗(特殊)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{row.roleType === "intent" && (
|
||||
<p className="text-[11px] leading-4 text-[var(--color-main,#0062D9)]">会商入口与收口智能体</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-9 items-center gap-2 max-lg:col-start-2 max-md:col-auto max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<span className="text-xs text-muted-foreground md:hidden">启用状态</span>
|
||||
<Switch checked={row.enabled} onCheckedChange={(checked) => toggleEnabled(row.id, checked)} aria-label={`${row.name || "岗位"}${row.enabled ? "已启用" : "已停用"}`} />
|
||||
<span className={cn("text-xs", row.enabled ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
|
||||
{row.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 max-lg:col-start-3 max-md:col-auto max-md:justify-start">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === 0} onClick={() => move(row.id, "up")} aria-label={`上移 ${row.name}`}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === rows.length - 1} onClick={() => move(row.id, "down")} aria-label={`下移 ${row.name}`}>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" disabled={row.isBuiltin} onClick={() => remove(row)} aria-label={`删除 ${row.name}`} title={row.isBuiltin ? "内置岗位不能删除,可停用" : "删除岗位"}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<footer className="border-t border-border/70 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-[-2px] text-emerald-600 dark:text-emerald-400" />
|
||||
当前已启用 {rows.filter((row) => row.enabled).length} 个岗位,其中意图识别岗:{rows.find((row) => row.id === enabledIntentId)?.name ?? "未配置"}。
|
||||
</footer>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import SkillAddressesPage from "./SkillAddressesPage";
|
||||
import TaskButtonsPage from "./TaskButtonsPage";
|
||||
import PositionRolesPage from "./PositionRolesPage";
|
||||
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
@@ -437,6 +438,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PositionRolesPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/login-whitelist"
|
||||
element={
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route';
|
||||
import {
|
||||
resolveChatsIframeEntryRedirect,
|
||||
resolveStandardChatFallback,
|
||||
} from '@/core/page-layout/chats-iframe-route';
|
||||
import { isEmbeddedHost } from '@/core/embed/frame-context';
|
||||
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
|
||||
@@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage';
|
||||
import AdminUserHubPage from './AdminUserHubPage';
|
||||
import TagsPage from './TagsPage';
|
||||
import PositionsPage from './PositionsPage';
|
||||
import PositionRolesPage from './PositionRolesPage';
|
||||
import PluginManagementPage from './PluginManagementPage';
|
||||
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
|
||||
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
@@ -240,13 +245,20 @@ function NewChatEntryRoute({
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
// Do not infer the chat experience from window.self !== window.top here.
|
||||
// A generic `/chats/new` deep link must render the same standard chat both
|
||||
// top-level and inside an iframe. The dedicated iframe protocol page remains
|
||||
// available only through its explicit `/chats_iframe/...` route (LoginPage
|
||||
// selects it for `from=iframe`). Otherwise a normal deep link was silently
|
||||
// redirected to IframeChatPage, whose new-thread state intentionally has no
|
||||
// manually selected agent and therefore stopped at "选择 Agent".
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
// A bare external iframe historically opens the dedicated AgentSelector
|
||||
// page. `resolveChatsIframeEntryRedirect` records that inferred context as
|
||||
// `from=iframe`; explicit full-chat/presentation parameters stay here.
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
@@ -256,12 +268,78 @@ function NewChatEntryRoute({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatsIndexEntryRoute({
|
||||
layoutProps,
|
||||
compactChrome,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
compactChrome: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage compactChrome={compactChrome} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatThreadEntryRoute({
|
||||
layoutProps,
|
||||
}: {
|
||||
layoutProps: {
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden: boolean;
|
||||
workspaceSidebarHidden: boolean;
|
||||
headerHidden: boolean;
|
||||
compactWorkspaceSidebar: boolean;
|
||||
compactActionsHidden: boolean;
|
||||
pageSidebarDark: boolean;
|
||||
forceWorkspaceSidebarCollapsed: boolean;
|
||||
};
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
|
||||
location.pathname,
|
||||
location.search,
|
||||
isEmbeddedHost(),
|
||||
);
|
||||
|
||||
if (iframeEntryTarget) {
|
||||
return <Navigate to={iframeEntryTarget} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy
|
||||
* Legacy links may still target it (or carry `from=iframe` during login), but
|
||||
* ordinary iframe/Wujie embedding needs the normal full chat workspace.
|
||||
* `chatMode=iframe` is the only explicit opt-in for integrations that really
|
||||
* implement the Brainy protocol.
|
||||
* `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
|
||||
* and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
|
||||
* remains on the normal full chat workspace.
|
||||
*/
|
||||
function IframeChatEntryRoute({
|
||||
layoutProps,
|
||||
@@ -341,9 +419,10 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="chats"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} />
|
||||
</WorkspaceLayout>
|
||||
<ChatsIndexEntryRoute
|
||||
layoutProps={layoutProps}
|
||||
compactChrome={headerHidden && !compactActionsHidden}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -352,13 +431,7 @@ export default function WorkspaceRoutes({
|
||||
/>
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
@@ -665,6 +738,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/position-roles"
|
||||
element={
|
||||
getStoredAuth()?.system_role === 'admin' ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PositionRolesPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user