Merge origin/master into xuhd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-28 18:31:16 +08:00
commit ebe1678b30
10 files changed
+731 -208

No files matched your search

@@ -362,14 +362,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
/>
<ResizablePanel
className={cn(
"bg-background transition-all duration-300 ease-in-out",
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-background p-4 transition-transform duration-300 ease-in-out",
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -36,15 +37,20 @@ import {
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent } from "./markdown-content";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
/**
@@ -76,6 +82,7 @@ export function MessageListItem({
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
@@ -83,6 +90,7 @@ export function MessageListItem({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const isHuman = message.type === "human";
@@ -101,6 +109,7 @@ export function MessageListItem({
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
{!isLoading && (
@@ -162,6 +171,7 @@ function MessageContent_({
isLoading = false,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
@@ -169,6 +179,7 @@ function MessageContent_({
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -188,6 +199,7 @@ function MessageContent_({
() => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins),
[stableExtraPlugins, baseRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
@@ -196,6 +208,12 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
return (
@@ -213,6 +231,11 @@ function MessageContent_({
[threadId, resolveArtifact],
);
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message);
@@ -232,8 +255,8 @@ function MessageContent_({
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const filesList =
files && files.length > 0 ? (
@@ -292,6 +315,7 @@ function MessageContent_({
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
);
}
@@ -299,22 +323,72 @@ function MessageContent_({
return (
<AIElementMessageContent className={className}>
{filesList}
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
className="my-3"
components={components}
/>
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
);
}
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
* timestamp is present.
*/
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return { label, full: date.toLocaleString() };
}
/**
* Small date+time label shown under a message. ``align`` controls the corner:
* ``right`` for the user's own question, ``left`` for the model's answer.
*/
function MessageTimeStamp({
time,
align = "right",
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
)}
title={time.full}
>
<ClockIcon className="size-3 shrink-0" />
<span>{time.label}</span>
</div>
);
}
/**
* Get file extension and check helpers
*/
@@ -482,3 +556,172 @@ function RichFileCard({
}
const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
renderSkillDisplayBlocks,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() =>
splitSkillDisplayPlaceholders(
content,
displaySources,
renderSkillDisplayBlocks,
),
[content, displaySources, renderSkillDisplayBlocks],
);
const fallbackBlocks = useMemo(() => {
if (!renderSkillDisplayBlocks) {
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
components={components}
/>
) : (
<SkillDisplayBlock
key={`display-${index}`}
mode={part.mode}
sources={part.sources}
/>
),
)}
{fallbackBlocks.map((group) => (
<SkillDisplayBlock
key={`fallback-${group.key}`}
mode={group.mode}
sources={group.sources}
/>
))}
</>
);
}
type DisplayPart =
| { type: "markdown"; content: string }
| {
type: "display";
key: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
};
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
renderSkillDisplayBlocks: boolean,
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
const placedKeys = new Set<string>();
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
}
cursor = start + match[0].length;
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
return false;
}
if (!tool) {
return true;
}
const left = normalizeName(group.skillName);
const right = normalizeName(tool);
return left === right || left.includes(right) || right.includes(left);
});
}
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
const groups = new Map<
string,
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
return [...groups.values()];
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -1,6 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Conversation,
@@ -8,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -20,6 +22,12 @@ import {
hasToolCalls,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
@@ -63,7 +71,6 @@ export function MessageList({
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -72,6 +79,31 @@ export function MessageList({
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet).
@@ -190,6 +222,16 @@ export function MessageList({
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
@@ -197,6 +239,7 @@ export function MessageList({
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/>
);
@@ -442,6 +485,24 @@ export type SetupAgentApprovalPayload = {
skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload(
message: unknown,
): SetupAgentApprovalPayload | null {
@@ -165,7 +165,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />}
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
</>
);
}
+55 -11
View File
@@ -7,6 +7,8 @@ import React, {
} from 'react';
import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型
export type ThemeType = 'dark' | 'light';
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
};
const STORAGE_KEY = 'strategy-theme';
// Marker that the user has explicitly toggled the theme (via the appearance
// settings page or any future toggle UI). When this flag is absent we treat
// any value stored in ``strategy-theme`` as one we put there ourselves while
// syncing the admin's default — so we'll re-sync on every boot, picking up
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
return saved === 'dark' ? 'dark' : 'light';
}
function hasExplicitChoice(): boolean {
if (typeof window === 'undefined') return false;
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
}
function markExplicitChoice(): void {
if (typeof window === 'undefined') return;
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
}
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false);
// Admin-configured global default. If the user hasn't explicitly chosen
// a theme yet, this value drives the rendered theme — and re-syncs on
// every mount so an admin change propagates without users clearing
// localStorage by hand.
const { data: appearance } = useAppearanceSettings();
useEffect(() => {
setMounted(true);
}, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
// 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => {
if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') {
const fallback =
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
setNextTheme(fallback);
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy);
if (current && current !== 'light' && current !== 'dark') {
// Garbage value — wipe it so the boot-sync below can take over.
localStorage.removeItem(STORAGE_KEY);
}
if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
// Migrate the legacy key as the user's explicit choice (it was set
// long enough ago that we should preserve their pick rather than
// override with admin default).
setNextTheme(legacy);
markExplicitChoice();
}
if (legacy) {
localStorage.removeItem('theme');
}
}, [mounted, setNextTheme]);
// Boot-time sync: when the user has never picked a theme themselves,
// apply whatever the admin configured. Re-runs whenever the admin
// setting changes (so a logged-in user sees the new default after
// refreshing without having to clear cookies).
useEffect(() => {
if (!mounted) return;
if (hasExplicitChoice()) return;
const adminDefault = appearance?.default_theme;
if (!adminDefault) return;
if (resolvedTheme !== adminDefault) {
setNextTheme(adminDefault);
}
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
const theme: ThemeType = mounted
? resolvedTheme === 'light'
? 'light'
: 'dark'
? resolvedTheme === 'dark'
? 'dark'
: 'light'
: readPersistedTheme();
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
markExplicitChoice();
};
const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme);
markExplicitChoice();
};
const themeConfig = themes[theme];
+156 -135
View File
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
export default function StrategyRoutes() {
return (
<RouteBaseProvider value="/page/strategy">
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
<SidebarProvider>
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
{/* XK任务 */}
<Route
path="xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
</Route>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<RecommendedQuestionsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<PromptPrefixSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/private-square"
element={
getStoredAuth()?.system_role === "admin" ? (
<PrivateSquareSettingsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<LlmMetricsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/users"
element={
getStoredAuth()?.system_role === "admin" ? (
<UsersPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminScheduledTasksPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/user-activity"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserActivityPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider>
);
}