sync update from LAN repo (source dhk-1 @ c21a2a4a)

This commit is contained in:
gozeros committed 2026-11-10 14:04:00 +08:00
1 parent 264e2200d6
commit a1fa13a238
122 files changed
+8881 -623

No files matched your search

+3
View File
@@ -62,6 +62,9 @@
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# 知识库视图切换:true=显示“列表 / 卡片”切换;false 或未设=固定为列表(默认)。
# 部署后优先改 dist/runtime-config.js 的 VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED,刷新即可。
# VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED=false
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+4
View File
@@ -147,6 +147,10 @@ window.__CM_RUNTIME_CONFIG__ = {
// 静态站点模式开关;一般部署业务系统时保持 false。
VITE_STATIC_WEBSITE_ONLY: "false",
// 知识库页面是否显示“列表 / 卡片”视图切换。默认 false:固定使用紧凑列表;
// 改为 true 并刷新页面后,才允许用户切换到卡片视图。
VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED: "false",
// 紧凑导航开关:true 时固定使用简洁布局,工作台左侧栏收起为菜单图标,
// 隐藏系统 Logo、换肤按钮以及“全量/简洁模式”切换。修改后刷新页面即可生效。
VITE_FORCE_COLLAPSED_SIDEBAR: "false",
@@ -93,6 +93,21 @@ const requiredLegacyFallbacks = [
pattern: /AbortSignal\.timeout = function/,
description: "Chrome 102 needs AbortSignal.timeout before any module loads",
},
{
file: "vite.config.ts",
pattern: /export function jsx\(\)\{return null\}/,
description: "Stale Vite deps chunks must locally export jsx for Chrome 102",
},
{
file: "vite.config.ts",
pattern: /export const \$\{name\} = __jsxRuntime\.\$\{name\}/,
description: "Vite jsx-runtime CJS interop must expose a named jsx export",
},
{
file: "src/components/workspace/question-navigator.tsx",
pattern: /animateScrollTop\(parent, Math\.max\(0, Math\.min\(nextTop, maxScrollTop\(parent\)\)\)\)/,
description: "Question list anchors must set nested scrollTop for Chrome 102",
},
{
file: "src/styles/globals.css",
pattern: /\.workspace-chat-bg,\s*\n\s*\.canvas-writing-bg\s*\{\s*background-color:\s*var\(--background\);/,
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="64" viewBox="0 0 1920 64"><image width="1920" height="64" preserveAspectRatio="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB4AAAABACAYAAAD77Wr8AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAP+lSURBVHhe7P1VdJxJuif89lqz9+5iM4Mkg2zLIGZmxmRmVIJYJpktMrOLqauaqpmqqbq4u6D3/jbOnHVuz8C5mIuZOff/s54n4s18M5WS5erqge+ci9+KiCfifVMm2c5/Rrzf+O//5T//1/8T/DdpubraSnOr819WkHyNR1+XXk//+v9LRpmvXV7qdZnn0qWvW4l6/eNem36f9NfPVHtcj3OPldZlmlvu3plqK0v/vZL2+/Q/C/9dsdLaDB41r16zmrXLyXR9ptpX81/+63/7f6ss+TkUP4+pr5WcW+7XKtOa9PpfKvN90398qV9v6vVLf33+j5P2+3eJ5ebU160k/TrpfzzOPb4W/ylDTakr/vN//R8sWaO+InVtZunr0q9V3+//7y/wn6T0+tdpuddQ6n+x/ygtN17q/yOl1v6jirhPau2rS35NS+f+9/D/UrVKXz33fyP/MUMtHa1ZSfq69OsfNfe4vs57/c+W/nP3KOrrlrtPhtf4H1LGNbKmrElZtwrq6zJJX/9VpN/z/xf891VIv2Yl6deudB917VHrVjP3l0i/X/rrfBXpr/G/2v/OX1d6TT230vz/DF/Hr2nK9f/pP6rQOL0m/cfUsfLvlyXr/o/znxL91B8T1ZdS/n2YXn8syv9d0uvLzWf4P9H/Ksq/19Pr//eX6f+36f/v/frQ/5nTa1+LR71HsNL7Eem15d5zeNQ90usZ3st4pPRrl7vPcvW/pvSv7a+Aft755/5/pcSvoerPQsoa1Xt/6fPyz5D6vV7lvd/U94CTfwb/25I/k+l/DtPry1F/7cnrkt/bls59HVK/b6Z/DX8d4r23pfWvw196b+X6v+Qej/L13TvTr9vSX+OVLHnPa9XUP470ucwyvWZy/PjvyS53f+XnIn0+fd1q65mkfy0rXbfadcpapf+N30yPI5PfTo+x9HrClJRez2hCWqauvg/fl2rKNcJvpyeF4xMJv6OW5+T6KTlWrXkk1bUpr7Oc4yrpc8utSyfX/Ob4FH57QvgNm5amEmvT66njdMr8FN+bayePJ6WvP6mguQzzmZycxq9Xcuq4ROMp0So1mj8hKeszjN89cRzvniTTwqnjePfUCdU9pvDrk3TPE/j16TS8Tkqfe5w1X4Xqvu+eJifx61Mn8bvpSbw/EcNHo2H2wdgwPhwNS0F8OBLEh3ESkq2sjYQEWjMWwofjIXwwEcb7ZDKEDydkbTyMDyekcVFjPFYk6x9NhFmiPhHEhxMBfDgeXGos1UcK1XyilhDAR2N+ifrquqo/TvcRaz8e9eODyWH87uQE3p05hV+dPY1fnTmFX505yX49cxK/Pk2/Ryfxu+kxvDcZx3tTI/jd8XH5e/KE+D3BfWqFR/++Ta4Rvw/TruM6/X4jqnsr66XEev5zp/wZpD/j0gmFaj7lz6tqLf/Zl9+fTkyylHnV90WBakLyexB9D1/BVKrfTY2x306NCpOj+N3EKH5LJkfwu8kR/F6ivlL73cQIfjcex2/HYimoxnWJx1RXzYlaHL8fjeH3I1EhHhFGo0lUp3Ysg/Eo3hun+Qh+PyKufW8kgvd4PIzfxyWaY6pamvfiYSE2rELjdCG8F1WhcSyEP6hFQ3ifRIL4Q1R4PxrA+9zKWiSwovcyjN8b9ifG76twbVh4P+JnfyC0ftiP99XCxMc+CPsZj0NefCBRn8dhFXVd5UNqg168H/SImmo91cmHkrKeUS1xvQfvBwTqfxgSqJ+cc+ODgBsfEr8bH5BEzYUP/RL1FUotzUd+p+Bz4iOvRH11Xc3rYB96nQlKTe1D1RoxtgseOz4iXoW8xm3LwKpiw0ceicYeyW3BR64M+BoLPnSZ8RHLtEb42GXBx04LPnKaEz52mASnCR/T9U4TPnIY8JHdgI8dEvV5rMcndjWDpMfHdh0+tumSrU2Hj2xafGTT4GObBp9YNfjEomWfWrX41EY0+NRKhvAJsZBBfErMwiemAfapeQCfmvrZJ8Y+fMp62SeGXnxq6MEfib47SdeNT7ntZH8i2g72GWtnf9K0JXymacMXQ6QVnw+14POhZnwx1IzPB4Uvhprw+WAjPh9oxBeDjfhyQPhC+nKgAX/ur8eXffX4gvQr6vBFX9KXsv28t5Z90VuLL0mPogZfkO501fhSov7n3K+RqvFlVxX7QrZCNb7orJIqVarweWclo/HnHcIXHRX4klE/6fPOCnzeUYHP26WOCnzRUY4vpS/ak76U1H3yZ/Vcm9RelpCcK8OXajwn17UJ6nmlpvZ5axk+z1RXXZ8+l7xvKb5oK8VnraX4vG2pL6jeKub/1FoiKeNSfNpSwv4ofSL9saWUUf+j5hJ80lyCP7WI6/5IWkr4vvTa9BqftZbgj63iXsr9ldekOt3nY9W96VoF3ZeuU16b8H1aSvCZRH2FUlPQ+g+aS/Bhs/haFcqYvtbEta2rIb9++vHKa5SfjxTKz4X8MdDP0YdN4nW5bUr9elLQnFyb6Mv2k6YSfNpcij82l3KbiuYk+WuW+vUK9PUsh+5L/iQpY3X9k6ZSfNBYkqD8eD5gxfiouTjxeyJd+v2UH8fH8lrysfw9RT8Omv+8pYwpX4/yY+U18veh+D2R/lrK7xf6cZfhy9YyfNlSxn26j/o65X5M/hryPej3kHwdNWVejX5uP2+h3/Nlq8ZfUyaqP9v0PYDXUi0xX54g/rwrY/HnXkiv0/ce9fey5PcqJr+X0feWz9sF8f1EfA/kOs+V4zNel/xeSTXFZyl9+f1Wou/LRN1X1/j7M3+PrsRnEn1v/yzxPV75O0CSf0983lW9FP/dUoPPJOoLtfi8py7hM0VvHf7E6vGZok/RgM/7GxM+U/cHmvB5vzTQjM8HWvDZQAs+H1TQ38MC/d38+VA7+0xDOvC5pgNfaDvwubYTn2m78Lmum30mW6bvwWeGXvzJ0IvPjH34zEit0u8XTP34k7kffzINCGbJMphkHUr4I9PgT3Ztwh9tevYnux5/sukEux5/tOvxKXEY8KnTyD5xiJa5zCom9onLLLipZuGW+x5rwiduFRozGz712tkn1HoE7ifqDnyc4MQnxOfExz4H+8RPYwfjmrKW+n5nqoBLxY2Pgx4hpBL04CMppS/neaysDXuTQl58RMLEt2ofhtJQTZFeS58P+4VhSV1LkGsTa5I+GFaJkMCqpPx/jaj+r/dBVFDXkv8PFP+/TEjMUV+F5tL/z6r6vyujfjwsxMJLaxkNCzEir+P/W6v+Ty3HYo3wHv2/PR5JXE/z4v/loq5c896IkJij/+vLWgrlOvlewHtjsqUxvb+gvB9A7xOQsQi/l8CU9xzU7ztkeh9CvY6MRfG7cfEex5J7jMfwe/l+x+9GYwL1FWnviSytJ99b+c1YHL/hVukLifdZlPdjqD8xgt/TezQTynsxI0kTAr+Po3pfh2vj8r0d1VzK+z7Ke0ET6tcUeEx19XX8NSxzT+X1lNdUKD8edS2d6msRP85HX/f7iVGBX1OspWsVia9FjesxvDc6jA/ofdpYEH+IBfn9JH6/i34uJ+m9szFu6df7/XgQH0U8+CTixsdRKeLCJxEXPh52c53nhiX1Ol4r6h8pcxHRV8afRD0S9YXEdYqoi9HrfDrsYZ8Q+XV9EnZJ9LWIuljjxifDLon6bnwapnsISo0p9wi58GnIhT9Kn4bFmHwSFD4O0X08+GPYw+3Hw158SO+F8Xt+8r1Apnr/j6n+rEr05/kPaiP0/UN+H6E+SVujfO9Y8v1iJfSe4IjE7xGqvofJ73vK9yuujSjCSfw9VH6/GxnG+0w1z2voe2sIfxiRrfL9WHkN1XuQie+tsTDejytCCR+MyH5MigeFmGyVPv99IN+rpHXK3xvKuhi9ZxkQYwW/j6nUZct/Z2Wg+vvqkWIS9fnvwSATX+PSvyNTpP2dmF4T46CQ+Ps1/e9c5WtOvk8q/t5WviYf3h8mNB9k4lr5d3tUtnytWJuoyznl7/PENYnrfPiA1itzUfEebvJrUd0v5Z7iutT3d+W91K+hQvdU+t8Y8fox4g1ixBeSgmLsDciW5hTKfAAjHj9GPMoaauk+/tR1Kdel11aop7+uN4hRX2iJMX8IY74gxvwBjPkCGPUHpRBG/WFJ1MZSBBLXJAUxRvdk1FdRrvMFMe4PCYFwsu+nPqH5IMYDRKkra5a5jtYx6g/LeWVNGGMBxbCqvwry2vHgMMaDEYn6S02o8deQoa6Scn1ISMxTX5E+XiIspV3zqOt4PiKEhzHJIpgMR5c1NRzFJFHXJHVfbenaCKbCw7JNXZN6bUzWY5gajuFkMITLLgfumDR4Rd+NVwzduG/sx3WTBlfMWly36HHHqsddq5Hdthpwm2s63LXqcMdqwB2LmLtrN+G23YRbNlqnT7hlEfg6iy5Nav2uWcvumJV5Le4SqpskuSaVBvfMGtxnWtyTdWqpLua0khgzkwb3jKK9b9LggXTfNIS71Jo1eNE4gJcMvbhtGsJ5px0TwRBGonGMxEYwGo1jNBLDaCQqhMKY8vlxyuPBWY8LM14vjvuDmMz0+0b9+1b5vaqqJ38vRzAWikryzwr/mQxjLBjGWGiY6wL1wxgNDjP6s8nrAmGMBmRNdb24h6jxvRLEn+nRQBgj9L2KrvUPY8QX5u+L/H1MuWdgmNfwnDeU/B7JaJwq7g0g7vVLQcLine truncated

After

Width:  |  Height:  |  Size: 193 KiB

@@ -443,6 +443,25 @@
background-size: 152px 42px;
}
/* 浅红科技感顶栏保持深色版的尺寸与排布,只替换文字光效和选中底图。 */
.strategy-app-header--light-red-top-nav .top-submenu-item {
color: rgba(255, 255, 255, 0.86);
}
.strategy-app-header--light-red-top-nav .top-submenu-item:hover {
color: #ffffff;
text-shadow: 0 0 10px rgba(196, 51, 40, 0.4);
}
.strategy-app-header--light-red-top-nav .top-submenu-item--active {
color: #ffffff;
text-shadow: 0 0 12px rgba(177, 38, 28, 0.48);
background-image: var(
--light-red-nav-active-background-image,
url("../assets/img/nav-active-light-red.svg")
);
}
.third-submenu-bar {
position: relative;
z-index: 34;
@@ -45,6 +45,8 @@ function HeaderButton({
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
export function WikiHeader({
title = "知识库管理",
description,
onToggleSidebar,
onImportFile,
onBatchImport,
@@ -55,6 +57,8 @@ export function WikiHeader({
onManageTemplates,
extraActions,
}: {
title?: string;
description?: string;
onToggleSidebar: () => void;
onImportFile: () => void;
onBatchImport: () => void;
@@ -66,8 +70,13 @@ export function WikiHeader({
extraActions?: ReactNode;
}) {
return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center gap-3">
<header
className={cn(
"flex shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900",
description ? "min-h-14 py-2" : "h-14",
)}
>
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onToggleSidebar}
@@ -76,8 +85,15 @@ export function WikiHeader({
>
<Menu className="size-5" />
</button>
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
知识库管理
<div className="min-w-0">
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{title}
</div>
{description ? (
<p className="mt-0.5 max-w-xl truncate text-xs text-slate-500 dark:text-slate-400">
{description}
</p>
) : null}
</div>
</div>
@@ -18,7 +18,12 @@ import type { Agent, AgentSort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useRouteBase } from "@/core/route-base";
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
import {
usePageDescriptionsSettings,
usePrivateSquareStatus,
usePublishSquares,
useViewMode,
} from "@/core/system-settings/hooks";
import {
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
WX_RESTRICTED_TAB_NANJING_DB,
@@ -51,6 +56,8 @@ export function AgentGallery({
const { t } = useI18n();
const router = useRouter();
const routeBase = useRouteBase();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.agents;
const sentimentAgentVisible = isSentimentAgentVisible();
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
const isAdmin = getStoredAuth()?.system_role === "admin";
@@ -455,9 +462,11 @@ export function AgentGallery({
{/* Page header */}
<div className="flex items-center justify-between border-b px-6 py-4">
<div>
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
<h1 className="text-xl font-semibold">
{pageDescription?.title || t.agents.title}
</h1>
<p className="text-muted-foreground mt-0.5 text-sm">
{t.agents.description}
{pageDescription?.description || t.agents.description}
</p>
</div>
<div className="flex items-center gap-2">
@@ -1706,8 +1706,16 @@ export function ArtifactFileDetail({
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
label={
language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载离线包"
: t.common.download
}
tooltip={
language === "html" && !useWriteFileStreaming && !onDownloadClick
? "下载包含网页运行库的 ZIP 离线包"
: t.common.download
}
onClick={async () => {
if (onDownloadClick) {
onDownloadClick();
@@ -130,7 +130,9 @@ export function ArtifactFileList({
) : (
<DownloadIcon className="size-4" />
)}
{t.common.download}
{!onDownloadArtifact && /\.(?:html?|xhtml)$/i.test(getFileName(file))
? "下载离线包"
: t.common.download}
</Button>
</CardAction>
</CardHeader>
@@ -3,7 +3,11 @@
import { useTheme } from "@/contexts/ThemeContext";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import {
useAppSystemName,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
import { readRuntimeFlag } from "@/env";
import { cn } from "@/lib/utils";
@@ -12,11 +16,25 @@ import { cn } from "@/lib/utils";
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
*/
export function CompactWelcome({ className }: { className?: string }) {
export function CompactWelcome({
className,
pageDescription,
}: {
className?: string;
/** The actual interface mode is resolved by ChatPage. */
pageDescription?: GeneralQaPageDescription;
}) {
const desensitizeText = useDisplayDesensitizer();
const { theme } = useTheme();
const systemName = useAppSystemName("");
const welcomeTitle = systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手";
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configured = pageDescription ?? pageDescriptions?.general_qa.compact;
const welcomeTitle =
configured?.welcome_title ||
(systemName ? `欢迎使用${systemName}` : "欢迎使用CM助手");
const welcomeDescription =
configured?.welcome_description ||
"基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。";
const isWelcomeLogoVisible = readRuntimeFlag("VITE_WELCOME_LOGO_VISIBLE");
return (
@@ -46,7 +64,7 @@ export function CompactWelcome({ className }: { className?: string }) {
{desensitizeText(welcomeTitle)}
</h1>
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
{desensitizeText(welcomeDescription)}
</p>
</div>
);
@@ -140,6 +140,7 @@ export function InputBox({
knowledgeRetrieval,
knowledgeSuggestedQuestions = [],
extraModels = [],
placeholder,
assistantId,
hideAdminRecommendedQuestions,
compact,
@@ -227,6 +228,8 @@ export function InputBox({
knowledgeSuggestedQuestions?: string[];
/** Page-scoped model choices implemented outside the DeerFlow model API. */
extraModels?: Model[];
/** Page-specific prompt. Omit to keep the localized product default. */
placeholder?: string;
}) {
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
@@ -861,7 +864,7 @@ export function InputBox({
data-composer-height={composerMinHeight ? "" : undefined}
style={composerHeightStyle}
disabled={disabled}
placeholder={t.inputBox.placeholder}
placeholder={placeholder || t.inputBox.placeholder}
autoFocus={autoFocus}
defaultValue={initialValue}
/>
@@ -63,6 +63,10 @@ import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isInternalToolCallName } from "@/core/tools/visibility";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "@/core/tools/skill-invocation";
import {
applyInlineCitations,
buildSkillDisplayMap,
@@ -371,6 +375,10 @@ function ToolCall({
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const invokedSkillName = extractInvokedSkillName(name, args);
const invokedSkillLabel = invokedSkillName
? skillInvocationLabel(invokedSkillName)
: undefined;
const displayMatch =
result !== undefined && isDisplaySamplePayload(result)
? null
@@ -566,7 +574,9 @@ function ToolCall({
} else if (name === "read_file") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
if (invokedSkillLabel) {
description = invokedSkillLabel;
} else if (!description) {
description = t.toolCalls.readFile;
}
const { path } = args as { path: string; content: string };
@@ -598,11 +608,10 @@ function ToolCall({
/>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return t.toolCalls.executeCommand;
}
const description =
invokedSkillLabel ||
(args as { description?: string })?.description ||
t.toolCalls.executeCommand;
const command: string | undefined = (args as { command: string })?.command;
return (
<ChainOfThoughtStep
@@ -653,7 +662,7 @@ function ToolCall({
return (
<ChainOfThoughtStep
key={id}
label={description ?? t.toolCalls.useTool(name)}
label={invokedSkillLabel ?? description ?? t.toolCalls.useTool(name)}
icon={WrenchIcon}
></ChainOfThoughtStep>
);
@@ -27,21 +27,146 @@ const PANEL_GUTTER_MARGIN = 24;
/** Questions needed before the navigator shows up at all. */
const MIN_QUESTIONS = 2;
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
/** Matches `scroll-mt-20` so the target sits below the overlay chat header. */
const QUESTION_SCROLL_TOP_OFFSET = 80;
/** Re-apply after the drawer overlay unmounts; StickToBottom may snap on resize. */
const SHEET_SCROLL_RETRY_MS = 80;
function verticalOverflow(node: HTMLElement): string {
const style = window.getComputedStyle(node);
const overflowY = style.overflowY;
if (overflowY && overflowY !== "visible") return overflowY;
const parts = style.overflow.split(" ");
return parts[1] || parts[0] || "";
}
function isVerticallyScrollable(node: HTMLElement): boolean {
const overflowY = verticalOverflow(node);
return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
}
/** Walk up from a message anchor to the element that actually scrolls. */
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = window.getComputedStyle(node).overflowY;
if (
(overflowY === "auto" || overflowY === "scroll") &&
node.scrollHeight > node.clientHeight
) {
while (node && node !== document.body) {
if (isVerticallyScrollable(node) && node.scrollHeight > node.clientHeight + 1) {
return node;
}
node = node.parentElement;
}
return null;
if (document.scrollingElement instanceof HTMLElement) {
return document.scrollingElement;
}
return document.documentElement;
}
let questionScrollFrame = 0;
let questionScrollTarget: { scroller: HTMLElement; top: number } | null = null;
function prefersReducedMotion(): boolean {
try {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
} catch {
return false;
}
}
/** Nested-container smooth scroll that Chrome 102 can actually run. */
function animateScrollTop(scroller: HTMLElement, top: number): void {
if (
questionScrollTarget?.scroller === scroller &&
Math.abs(questionScrollTarget.top - top) < 8
) {
return;
}
if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame);
const from = scroller.scrollTop;
const delta = top - from;
questionScrollTarget = { scroller, top };
if (Math.abs(delta) < 1 || prefersReducedMotion()) {
scroller.scrollTop = top;
questionScrollFrame = 0;
return;
}
const duration = Math.min(480, Math.max(240, Math.abs(delta) * 0.45));
const start = performance.now();
const stopListening = () => {
scroller.removeEventListener("wheel", cancelOnUserScroll);
scroller.removeEventListener("touchmove", cancelOnUserScroll);
};
const cancelOnUserScroll = () => {
if (questionScrollFrame) cancelAnimationFrame(questionScrollFrame);
questionScrollFrame = 0;
questionScrollTarget = null;
stopListening();
};
const step = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - (1 - t) * (1 - t) * (1 - t);
scroller.scrollTop = from + delta * eased;
if (t < 1) {
questionScrollFrame = requestAnimationFrame(step);
return;
}
questionScrollFrame = 0;
stopListening();
};
scroller.addEventListener("wheel", cancelOnUserScroll, { passive: true });
scroller.addEventListener("touchmove", cancelOnUserScroll, { passive: true });
questionScrollFrame = requestAnimationFrame(step);
}
function scrollChildIntoParent(
el: HTMLElement,
parent: HTMLElement,
topOffset = 0,
): void {
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const nextTop = parent.scrollTop + (elRect.top - parentRect.top) - topOffset;
animateScrollTop(parent, Math.max(0, Math.min(nextTop, maxScrollTop(parent))));
}
function maxScrollTop(parent: HTMLElement): number {
return Math.max(0, parent.scrollHeight - parent.clientHeight);
}
/**
* Chrome 102 (and other nested-scroller hosts) often no-ops
* `scrollIntoView({ behavior: "smooth" })` when the conversation is not the
* document. Animate `scrollTop` on the real overflow container instead.
*/
function scrollElementIntoContainer(el: HTMLElement, topOffset = 0): void {
const scroller = findScrollParent(el);
if (!scroller || scroller === el) {
try {
el.scrollIntoView(true);
} catch {
// ignore hosts that reject the boolean form
}
return;
}
try {
scroller.dispatchEvent(
new WheelEvent("wheel", { deltaY: -1, bubbles: true }),
);
} catch {
// WheelEvent is available on Chrome 102; keep scrollTop even if this fails.
}
scrollChildIntoParent(el, scroller, topOffset);
}
function scrollItemNearestInParent(
item: HTMLElement,
parent: HTMLElement,
): void {
const parentRect = parent.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
if (itemRect.top < parentRect.top) {
parent.scrollTop += itemRect.top - parentRect.top;
} else if (itemRect.bottom > parentRect.bottom) {
parent.scrollTop += itemRect.bottom - parentRect.bottom;
}
}
function readCollapsedPreference(): boolean {
@@ -113,6 +238,7 @@ export function QuestionNavigator({
return;
}
const firstAnchor = document.getElementById(questions[0]!.anchorId);
if (!firstAnchor) return;
const scroller = findScrollParent(firstAnchor);
if (!scroller) return;
@@ -145,17 +271,20 @@ export function QuestionNavigator({
const scrollToQuestion = useCallback((anchorId: string) => {
const scroll = () => {
document
.getElementById(anchorId)
?.scrollIntoView({ behavior: "smooth", block: "start" });
const el = document.getElementById(anchorId);
if (!el) return false;
scrollElementIntoContainer(el, QUESTION_SCROLL_TOP_OFFSET);
return true;
};
if (!document.getElementById(anchorId)) {
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
requestAnimationFrame(() => requestAnimationFrame(scroll));
} else {
scroll();
}
setActiveId(anchorId);
if (scroll()) return;
window.dispatchEvent(new Event(REVEAL_CHAT_HISTORY_EVENT));
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll()) return;
window.setTimeout(scroll, 50);
});
});
}, []);
const toggleCollapsed = useCallback(() => {
@@ -246,8 +375,12 @@ export function QuestionNavigator({
questions={questions}
activeId={activeId}
onSelect={(anchorId) => {
setSheetOpen(false);
scrollToQuestion(anchorId);
setSheetOpen(false);
window.setTimeout(
() => scrollToQuestion(anchorId),
SHEET_SCROLL_RETRY_MS,
);
}}
className="min-h-0 flex-1"
/>
@@ -272,17 +405,22 @@ function QuestionCardList({
className?: string;
}) {
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
const navRef = useRef<HTMLElement | null>(null);
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
// nudges this inner list — the card is already inside the on-screen panel,
// so no outer container moves.
// Keep the active card visible as the chat scrolls. Scroll only this inner
// list — `scrollIntoView({ block: "nearest" })` on Chrome 102 also moves
// ancestor conversation scrollers, which undoes the jump to the question.
useEffect(() => {
if (!activeId) return;
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
const item = itemRefs.current.get(activeId);
const nav = navRef.current;
if (!item || !nav) return;
scrollItemNearestInParent(item, nav);
}, [activeId]);
return (
<nav
ref={navRef}
aria-label="问题导航"
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
>
@@ -1,9 +1,18 @@
"use client";
import { ImageUp, Loader2, RotateCcw } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type RefObject,
} from "react";
import { toast } from "sonner";
import defaultLightRedNavActive from "@/assets/img/nav-active-light-red.svg";
import defaultLightRedHeaderBackground from "@/assets/img/top-bg-light-red.svg";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -13,17 +22,35 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext";
import {
getHeaderBrandAsset,
getHeaderBrandFallback,
} from "@/core/branding/brand-assets";
import { resolveAppIdentityLogoUrl } from "@/core/system-settings/api";
import {
clearHeaderTitlePreview,
setHeaderTitlePreview,
} from "@/core/branding/header-title-preview";
import {
clearLightRedNavigationPreview,
setLightRedNavigationPreview,
} from "@/core/branding/light-red-navigation-preview";
import {
resolveAppIdentityAssetUrl,
resolveAppIdentityLogoUrl,
type AppIdentityHeaderTitleFontMode,
} from "@/core/system-settings/api";
import {
useAppIdentitySettings,
useDeleteAppIdentitySettings,
useUpdateAppIdentitySettings,
} from "@/core/system-settings/hooks";
import {
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
HEADER_TITLE_FIXED_MIN_FONT_SIZE,
HEADER_TITLE_MAX_FONT_SIZE,
} from "@/strategy-components/header-title-fit";
import { SettingsSection } from "./settings-section";
@@ -33,6 +60,121 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/jpeg",
"image/webp",
]);
const ACCEPTED_NAVIGATION_IMAGE_TYPES = new Set([
...ACCEPTED_LOGO_TYPES,
"image/svg+xml",
]);
const isAcceptedNavigationImage = (file: File) =>
ACCEPTED_NAVIGATION_IMAGE_TYPES.has(file.type) ||
(file.type === "" && file.name.toLowerCase().endsWith(".svg"));
const clampHeaderTitleFontSize = (value: number) =>
Math.min(
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, Math.round(value)),
);
interface NavigationImageUploadCardProps {
inputId: string;
inputRef: RefObject<HTMLInputElement | null>;
title: string;
description: string;
previewUrl: string;
previewAlt: string;
previewImageClassName: string;
pendingFile: File | null;
hasCustomImage: boolean;
restoringDefault: boolean;
disabled: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onRestoreDefault: () => void;
}
function NavigationImageUploadCard({
inputId,
inputRef,
title,
description,
previewUrl,
previewAlt,
previewImageClassName,
pendingFile,
hasCustomImage,
restoringDefault,
disabled,
onChange,
onRestoreDefault,
}: NavigationImageUploadCardProps) {
return (
<div className="grid gap-4 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(220px,2fr)_minmax(180px,1fr)]">
<div className="flex h-28 items-center justify-center overflow-hidden rounded-lg border border-dashed bg-slate-950/90 p-2">
<img
src={previewUrl}
alt={previewAlt}
className={previewImageClassName}
loading="lazy"
/>
</div>
<div className="flex min-w-0 flex-col justify-center gap-3">
<div>
<div className="text-sm font-medium">{title}</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{description}
</p>
</div>
<input
ref={inputRef}
id={inputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg"
aria-label={`${title}文件`}
className="sr-only"
onChange={onChange}
disabled={disabled}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="h-11"
onClick={() => inputRef.current?.click()}
disabled={disabled}
>
<ImageUp aria-hidden />
{hasCustomImage || pendingFile ? "更换图片" : "上传图片"}
</Button>
<Button
type="button"
variant="outline"
className="h-11"
onClick={onRestoreDefault}
disabled={
disabled ||
(!hasCustomImage && !pendingFile) ||
(restoringDefault && !pendingFile)
}
>
恢复默认
</Button>
</div>
{pendingFile && (
<p
className="truncate text-xs text-muted-foreground"
title={pendingFile.name}
>
待上传:{pendingFile.name}
</p>
)}
{restoringDefault && !pendingFile && (
<p className="text-xs text-muted-foreground">
保存后使用当前内置图片。
</p>
)}
</div>
</div>
);
}
export function AppIdentitySettingsPage() {
const { theme } = useTheme();
@@ -43,22 +185,72 @@ export function AppIdentitySettingsPage() {
const updateIdentity = useUpdateAppIdentitySettings();
const deleteIdentity = useDeleteAppIdentitySettings();
const fileInputRef = useRef<HTMLInputElement>(null);
const headerBackgroundInputRef = useRef<HTMLInputElement>(null);
const navActiveInputRef = useRef<HTMLInputElement>(null);
const [systemName, setSystemName] = useState("");
const [headerTitleFontMode, setHeaderTitleFontMode] =
useState<AppIdentityHeaderTitleFontMode>("adaptive");
const [headerTitleFontSize, setHeaderTitleFontSize] = useState(
HEADER_TITLE_MAX_FONT_SIZE,
);
const [pendingLogo, setPendingLogo] = useState<File | null>(null);
const [removeLogo, setRemoveLogo] = useState(false);
const [pendingHeaderBackground, setPendingHeaderBackground] =
useState<File | null>(null);
const [removeHeaderBackground, setRemoveHeaderBackground] = useState(false);
const [pendingNavActive, setPendingNavActive] = useState<File | null>(null);
const [removeNavActive, setRemoveNavActive] = useState(false);
const [navigationImagesTouched, setNavigationImagesTouched] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
useEffect(() => {
setSystemName(identity?.system_name ?? "");
setHeaderTitleFontMode(identity?.header_title_font_mode ?? "adaptive");
setHeaderTitleFontSize(
clampHeaderTitleFontSize(
identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE,
),
);
setPendingLogo(null);
setRemoveLogo(false);
setPendingHeaderBackground(null);
setRemoveHeaderBackground(false);
setPendingNavActive(null);
setRemoveNavActive(false);
setNavigationImagesTouched(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [identity?.system_name, identity?.logo_version, identity?.has_logo]);
if (headerBackgroundInputRef.current) {
headerBackgroundInputRef.current.value = "";
}
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
}, [
identity?.system_name,
identity?.header_title_font_mode,
identity?.header_title_font_size,
identity?.logo_version,
identity?.has_logo,
identity?.light_red_header_background_version,
identity?.has_light_red_header_background,
identity?.light_red_nav_active_version,
identity?.has_light_red_nav_active,
]);
const previewUrl = useMemo(
() => (pendingLogo ? URL.createObjectURL(pendingLogo) : undefined),
[pendingLogo],
);
const headerBackgroundPreviewUrl = useMemo(
() =>
pendingHeaderBackground
? URL.createObjectURL(pendingHeaderBackground)
: undefined,
[pendingHeaderBackground],
);
const navActivePreviewUrl = useMemo(
() =>
pendingNavActive ? URL.createObjectURL(pendingNavActive) : undefined,
[pendingNavActive],
);
useEffect(
() => () => {
@@ -67,16 +259,86 @@ export function AppIdentitySettingsPage() {
[previewUrl],
);
useEffect(
() => () => {
if (headerBackgroundPreviewUrl) {
URL.revokeObjectURL(headerBackgroundPreviewUrl);
}
},
[headerBackgroundPreviewUrl],
);
useEffect(
() => () => {
if (navActivePreviewUrl) URL.revokeObjectURL(navActivePreviewUrl);
},
[navActivePreviewUrl],
);
useEffect(
() => () => {
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
},
[],
);
const savedLogoUrl = removeLogo
? undefined
: resolveAppIdentityLogoUrl(identity?.logo_url);
const displayedLogoUrl = previewUrl ?? savedLogoUrl;
const savedHeaderBackgroundUrl = removeHeaderBackground
? undefined
: resolveAppIdentityAssetUrl(
identity?.light_red_header_background_url,
);
const savedNavActiveUrl = removeNavActive
? undefined
: resolveAppIdentityAssetUrl(identity?.light_red_nav_active_url);
const displayedHeaderBackgroundUrl =
headerBackgroundPreviewUrl ??
savedHeaderBackgroundUrl ??
defaultLightRedHeaderBackground;
const displayedNavActiveUrl =
navActivePreviewUrl ?? savedNavActiveUrl ?? defaultLightRedNavActive;
useEffect(() => {
if (!navigationImagesTouched) return;
setLightRedNavigationPreview({
headerBackgroundUrl:
headerBackgroundPreviewUrl ?? savedHeaderBackgroundUrl ?? null,
navActiveUrl: navActivePreviewUrl ?? savedNavActiveUrl ?? null,
});
}, [
headerBackgroundPreviewUrl,
navigationImagesTouched,
navActivePreviewUrl,
savedHeaderBackgroundUrl,
savedNavActiveUrl,
]);
const normalizedName = systemName.trim().replace(/\s+/g, " ");
const hasChanges =
normalizedName !== (identity?.system_name ?? "") ||
headerTitleFontMode !==
(identity?.header_title_font_mode ?? "adaptive") ||
clampHeaderTitleFontSize(headerTitleFontSize) !==
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) ||
Boolean(pendingLogo) ||
removeLogo;
const customized = Boolean(identity?.system_name || identity?.has_logo);
removeLogo ||
Boolean(pendingHeaderBackground) ||
removeHeaderBackground ||
Boolean(pendingNavActive) ||
removeNavActive;
const customized = Boolean(
identity?.system_name ||
identity?.has_logo ||
identity?.has_light_red_header_background ||
identity?.has_light_red_nav_active ||
(identity?.header_title_font_mode ?? "adaptive") !== "adaptive" ||
(identity?.header_title_font_size ?? HEADER_TITLE_MAX_FONT_SIZE) !==
HEADER_TITLE_MAX_FONT_SIZE,
);
const busy = updateIdentity.isPending || deleteIdentity.isPending;
const handleLogoChange = (event: ChangeEvent<HTMLInputElement>) => {
@@ -102,26 +364,82 @@ export function AppIdentitySettingsPage() {
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleHeaderBackgroundChange = (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
if (!isAcceptedNavigationImage(file)) {
toast.error("导航栏背景图仅支持 PNG、JPG、WebP 或 SVG 图片");
event.target.value = "";
return;
}
setPendingHeaderBackground(file);
setRemoveHeaderBackground(false);
setNavigationImagesTouched(true);
};
const handleNavActiveChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!isAcceptedNavigationImage(file)) {
toast.error("菜单选中样式图仅支持 PNG、JPG、WebP 或 SVG 图片");
event.target.value = "";
return;
}
setPendingNavActive(file);
setRemoveNavActive(false);
setNavigationImagesTouched(true);
};
const handleUseDefaultHeaderBackground = () => {
setPendingHeaderBackground(null);
setRemoveHeaderBackground(true);
setNavigationImagesTouched(true);
if (headerBackgroundInputRef.current) {
headerBackgroundInputRef.current.value = "";
}
};
const handleUseDefaultNavActive = () => {
setPendingNavActive(null);
setRemoveNavActive(true);
setNavigationImagesTouched(true);
if (navActiveInputRef.current) navActiveInputRef.current.value = "";
};
const handleSave = async () => {
try {
await updateIdentity.mutateAsync({
systemName: normalizedName,
headerTitleFontMode,
headerTitleFontSize: clampHeaderTitleFontSize(headerTitleFontSize),
logo: pendingLogo,
removeLogo,
lightRedHeaderBackground: pendingHeaderBackground,
removeLightRedHeaderBackground: removeHeaderBackground,
lightRedNavActive: pendingNavActive,
removeLightRedNavActive: removeNavActive,
});
toast.success("系统名称已保存", {
description: "导航、侧栏和欢迎区会使用这里的名称和 Logo。",
setNavigationImagesTouched(false);
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
toast.success("系统名称配置已保存", {
description: "导航名称、Logo 与红色主题图片已更新。",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "系统名称保存失败");
toast.error(error instanceof Error ? error.message : "系统名称配置保存失败");
}
};
const handleReset = async () => {
try {
await deleteIdentity.mutateAsync();
setNavigationImagesTouched(false);
clearHeaderTitlePreview();
clearLightRedNavigationPreview();
setResetOpen(false);
toast.success("已恢复默认系统名称和 Logo");
toast.success("已恢复默认系统名称、Logo、字号模式和导航图片");
} catch (error) {
toast.error(error instanceof Error ? error.message : "清除系统名称失败");
}
@@ -131,7 +449,7 @@ export function AppIdentitySettingsPage() {
<>
<SettingsSection
title="系统名称"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo。名称留空时继续使用现有名称;没有上传 Logo 时继续使用内置静态图片。这里不会改「生成网页标识」。"
description="修改本系统导航栏、侧栏和欢迎区显示的名称与 Logo,并设置顶部导航中的名称字号及红色主题图片。未上传自定义图片时继续使用当前内置图片。这里不会改「生成网页标识」。"
>
<div className="space-y-6">
<div className="space-y-2">
@@ -152,6 +470,131 @@ export function AppIdentitySettingsPage() {
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">导航栏名称字号</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
自适应会根据导航栏凹槽的可用宽度自动调整;固定大小始终使用指定字号。
</p>
</div>
<ToggleGroup
type="single"
value={headerTitleFontMode}
onValueChange={(value) => {
if (value) {
const mode = value as AppIdentityHeaderTitleFontMode;
setHeaderTitleFontMode(mode);
setHeaderTitlePreview({
mode,
fontSize: clampHeaderTitleFontSize(headerTitleFontSize),
});
}
}}
variant="outline"
aria-label="导航栏名称字号模式"
disabled={isPending || busy}
className="w-full sm:w-fit"
>
<ToggleGroupItem
value="adaptive"
className="h-11 flex-1 px-5 sm:flex-none"
>
自适应
</ToggleGroupItem>
<ToggleGroupItem
value="fixed"
className="h-11 flex-1 px-5 sm:flex-none"
>
固定大小
</ToggleGroupItem>
</ToggleGroup>
{headerTitleFontMode === "fixed" ? (
<div className="grid gap-3 rounded-lg border bg-muted/20 p-4 sm:grid-cols-[minmax(0,1fr)_112px] sm:items-center">
<div className="space-y-2">
<label
htmlFor="app-identity-header-font-size-range"
className="text-sm font-medium"
>
固定字号
</label>
<input
id="app-identity-header-font-size-range"
type="range"
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
step={1}
value={clampHeaderTitleFontSize(headerTitleFontSize)}
onChange={(event) => {
const fontSize = Number(event.target.value);
setHeaderTitleFontSize(fontSize);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize,
});
}}
disabled={isPending || busy}
className="h-11 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-valuetext={`${clampHeaderTitleFontSize(headerTitleFontSize)} 像素`}
/>
<div className="flex justify-between text-xs tabular-nums text-muted-foreground">
<span>{HEADER_TITLE_FIXED_MIN_FONT_SIZE}px</span>
<span>{HEADER_TITLE_FIXED_MAX_FONT_SIZE}px</span>
</div>
</div>
<div className="space-y-2">
<label
htmlFor="app-identity-header-font-size"
className="text-sm font-medium"
>
字号数值
</label>
<div className="relative">
<Input
id="app-identity-header-font-size"
type="number"
min={HEADER_TITLE_FIXED_MIN_FONT_SIZE}
max={HEADER_TITLE_FIXED_MAX_FONT_SIZE}
step={1}
value={headerTitleFontSize}
onChange={(event) => {
const value = Number(event.target.value);
if (Number.isFinite(value)) {
setHeaderTitleFontSize(value);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize: clampHeaderTitleFontSize(value),
});
}
}}
onBlur={() => {
const fontSize =
clampHeaderTitleFontSize(headerTitleFontSize);
setHeaderTitleFontSize(fontSize);
setHeaderTitlePreview({
mode: headerTitleFontMode,
fontSize,
});
}}
disabled={isPending || busy}
className="h-11 pr-9 tabular-nums"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
px
</span>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground sm:col-span-2">
拖动滑杆时顶部导航会同步预览。固定模式不会随分辨率缩放;名称过长时仍保持单行并完整显示,可能超出装饰区域。
</p>
</div>
) : (
<p className="rounded-lg border border-dashed bg-muted/10 px-4 py-3 text-xs leading-5 text-muted-foreground">
当前使用自适应模式:最大 30px,空间不足时逐步缩小,最小 24px。
</p>
)}
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">系统 Logo</div>
@@ -226,6 +669,47 @@ export function AppIdentitySettingsPage() {
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-sm font-medium">红色主题导航图片</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
分别配置顶部导航栏背景和菜单选中样式。选择图片或恢复默认时,页面顶部会立即同步预览;保存后对所有用户生效。
</p>
</div>
<NavigationImageUploadCard
inputId="app-identity-light-red-header-background"
inputRef={headerBackgroundInputRef}
title="导航栏背景图"
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用 1920×64 或相同比例的横向通栏图片。"
previewUrl={displayedHeaderBackgroundUrl}
previewAlt="红色导航栏背景图预览"
previewImageClassName="h-full w-full object-cover"
pendingFile={pendingHeaderBackground}
hasCustomImage={Boolean(
identity?.has_light_red_header_background,
)}
restoringDefault={removeHeaderBackground}
disabled={isPending || busy}
onChange={handleHeaderBackgroundChange}
onRestoreDefault={handleUseDefaultHeaderBackground}
/>
<NavigationImageUploadCard
inputId="app-identity-light-red-nav-active"
inputRef={navActiveInputRef}
title="菜单选中样式图"
description="支持 PNG、JPG、WebP、SVG;不限制文件大小。建议使用与当前 152×42 比例接近的透明图片。"
previewUrl={displayedNavActiveUrl}
previewAlt="红色导航菜单选中样式图预览"
previewImageClassName="max-h-full max-w-full object-contain"
pendingFile={pendingNavActive}
hasCustomImage={Boolean(identity?.has_light_red_nav_active)}
restoringDefault={removeNavActive}
disabled={isPending || busy}
onChange={handleNavActiveChange}
onRestoreDefault={handleUseDefaultNavActive}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-5">
<Button
type="button"
@@ -244,7 +728,7 @@ export function AppIdentitySettingsPage() {
disabled={!hasChanges || isPending || busy}
>
{updateIdentity.isPending && <Loader2 className="animate-spin" aria-hidden />}
{updateIdentity.isPending ? "保存中..." : "保存系统名称"}
{updateIdentity.isPending ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
@@ -253,10 +737,10 @@ export function AppIdentitySettingsPage() {
<Dialog open={resetOpen} onOpenChange={setResetOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除系统名称和 Logo?</DialogTitle>
<DialogTitle>清除系统名称配置?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
清除后,导航和欢迎区恢复为原来的名称,Logo 恢复为内置静态图片。
清除后,导航和欢迎区恢复为原来的名称,Logo 与红色主题导航图片恢复为内置静态图片,名称字号恢复为自适应。
</p>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setResetOpen(false)}>
@@ -92,6 +92,7 @@ import PrivateSquareSettingsPage from "@/pages/PrivateSquareSettingsPage";
import PublishSquaresSettingsPage from "@/pages/PublishSquaresSettingsPage";
import PromptPrefixSettingsPage from "@/pages/PromptPrefixSettingsPage";
import SkillCompressionSettingsPage from "@/pages/SkillCompressionSettingsPage";
import SkillVersioningSettingsPage from "@/pages/SkillVersioningSettingsPage";
import { CustomThemeLibrarySettings } from "./custom-theme-library-settings";
import { SettingsSection } from "./settings-section";
@@ -1557,6 +1558,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<PublishSquaresSettingsPage embedded />
<Separator />
<SkillCompressionSettingsPage embedded />
<Separator />
<SkillVersioningSettingsPage embedded />
</>
)}
</div>
@@ -0,0 +1,68 @@
"use client";
import { BarChart3Icon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useCallMetricsSettings,
useUpdateCallMetricsSettings,
} from "@/core/system-settings/hooks";
export function CallMetricsSettingsPage() {
const { data, isError, isLoading } = useCallMetricsSettings();
const update = useUpdateCallMetricsSettings();
const enabled = data?.enabled ?? true;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "工具与技能调用统计已开启" : "工具与技能调用统计已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BarChart3Icon className="size-5" />
工具与技能调用统计
</h3>
<p className="text-muted-foreground text-sm">
为综合管理中的“最常用工具”和“最常用技能”记录数据来源。统计会记录调用名称、
技能名称、耗时、状态及失败时的简短错误摘要,不记录工具参数或输出内容。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">记录工具与技能调用</div>
<p className="text-muted-foreground text-sm">
关闭后停止写入新的统计记录;已有记录仍可在综合管理中查看。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="记录工具与技能调用统计"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取调用统计开关,系统将按当前部署配置执行。
</p>
)}
<p className="text-muted-foreground text-xs">
保存后对下一次工具调用立即生效,无需重启后台;正在执行中的调用不会中途切换。
</p>
</section>
);
}
@@ -0,0 +1,200 @@
"use client";
import { AlertCircleIcon, Loader2Icon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { buildWikiKnowledgeBaseCreateInput } from "@/core/llmwiki/knowledge-base-form";
import {
useLlmWikiActions,
useLlmWikiEmbeddingModels,
} from "@/strategy-components/api/llmwiki";
import { SettingsSection } from "./settings-section";
type BatchCreateResult = {
createdNames: string[];
failed: Array<{ name: string; message: string }>;
};
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function parseKnowledgeBaseNames(value: string): string[] {
const seen = new Set<string>();
return value
.split(/[,,\n\r]+/)
.map((name) => name.trim())
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
});
}
export function KnowledgeBatchCreateSettingsPage() {
const actions = useLlmWikiActions();
const [namesText, setNamesText] = useState("");
const [embeddingModelId, setEmbeddingModelId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<BatchCreateResult | null>(null);
const embeddingModels = useLlmWikiEmbeddingModels();
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
const names = useMemo(() => parseKnowledgeBaseNames(namesText), [namesText]);
useEffect(() => {
if (!embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data]);
const modelSelectionUnavailable =
embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const suffix = [model.provider?.trim(), model.model_name?.trim()]
.filter(Boolean)
.join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const handleCreate = async () => {
if (names.length === 0 || modelSelectionUnavailable) return;
setSubmitting(true);
setResult(null);
actions.create.reset();
const nextResult: BatchCreateResult = { createdNames: [], failed: [] };
for (const name of names) {
try {
await actions.create.mutateAsync(buildWikiKnowledgeBaseCreateInput({
name,
description: "",
embeddingModelId,
wikiConfig: {},
}));
nextResult.createdNames.push(name);
} catch (error) {
nextResult.failed.push({ name, message: messageOf(error) });
}
}
setResult(nextResult);
setSubmitting(false);
if (nextResult.createdNames.length > 0) {
toast.success(`已创建 ${nextResult.createdNames.length} 个知识库`);
}
if (nextResult.failed.length > 0) {
setNamesText(nextResult.failed.map((item) => item.name).join(","));
toast.error(`${nextResult.failed.length} 个知识库创建失败,可修改后重试`);
} else {
setNamesText("");
}
};
return (
<SettingsSection
title="批量新增知识库"
description="输入多个知识库名称后,系统会在浏览器端按顺序逐个创建到你的个人知识库。"
>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-names">
知识库名称
</label>
<Textarea
id="knowledge-batch-names"
value={namesText}
onChange={(event) => setNamesText(event.target.value)}
rows={7}
maxLength={10000}
disabled={submitting}
placeholder="例如:产品手册,项目资料库,常见问题"
/>
<p className="text-xs text-muted-foreground">
使用中文逗号、英文逗号或换行分隔;重复名称会自动忽略。已识别 {names.length} 个名称。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="knowledge-batch-embedding-model">
原文向量模型
</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground">
<Loader2Icon className="mr-2 size-4 animate-spin" />正在读取知识服务模型
</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive" role="alert">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>
重新读取
</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={submitting}>
<SelectTrigger id="knowledge-batch-embedding-model" className="w-full">
<SelectValue placeholder="请选择向量模型" />
</SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
知识服务中没有可用的 Embedding 模型,请先在知识服务中启用向量模型。
</div>
)}
</div>
{result && (
<div
className={result.failed.length
? "rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
: "rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 text-sm text-emerald-700 dark:text-emerald-300"}
role={result.failed.length ? "alert" : "status"}
>
{result.createdNames.length > 0 && <p className="font-medium">已创建 {result.createdNames.length} 个知识库</p>}
{result.failed.length > 0 && (
<>
<p className="flex items-center gap-2 font-medium"><AlertCircleIcon className="size-4" />{result.failed.length} 个名称未创建</p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-xs leading-5">
{result.failed.slice(0, 5).map((item) => <li key={item.name}>{item.name}:{item.message}</li>)}
{result.failed.length > 5 && <li>其余 {result.failed.length - 5} 个失败项已保留在输入框中。</li>}
</ul>
</>
)}
</div>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleCreate()} disabled={submitting || names.length === 0 || modelSelectionUnavailable}>
{submitting && <Loader2Icon className="mr-2 size-4 animate-spin" />}
{submitting ? "正在创建" : `创建${names.length ? `(${names.length})` : ""}`}
</Button>
</div>
</div>
</SettingsSection>
);
}
@@ -25,6 +25,9 @@ export function RoundtableRoomSettingsPage() {
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
String(data?.max_hold_minutes ?? 30),
);
const [leaseMinutes, setLeaseMinutes] = useState(
String(data?.lease_minutes ?? 5),
);
const [maxCycles, setMaxCycles] = useState(String(data?.max_cycles ?? 8));
const [activeCount, setActiveCount] = useState<number | null>(null);
@@ -38,6 +41,12 @@ export function RoundtableRoomSettingsPage() {
}
}, [data?.max_hold_minutes]);
useEffect(() => {
if (data?.lease_minutes != null) {
setLeaseMinutes(String(data.lease_minutes));
}
}, [data?.lease_minutes]);
useEffect(() => {
if (data?.max_cycles != null) setMaxCycles(String(data.max_cycles));
}, [data?.max_cycles]);
@@ -65,6 +74,7 @@ export function RoundtableRoomSettingsPage() {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
lease_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}) => {
@@ -94,6 +104,15 @@ export function RoundtableRoomSettingsPage() {
void save({ max_hold_minutes: parsed });
};
const saveLeaseMinutes = () => {
const parsed = Number(leaseMinutes);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 60) {
toast.error("房间租约请填写 1 到 60 的整数");
return;
}
void save({ lease_minutes: parsed });
};
const saveMaxCycles = () => {
const parsed = Number(maxCycles);
if (!Number.isInteger(parsed) || parsed < 2 || parsed > 100) {
@@ -111,7 +130,7 @@ export function RoundtableRoomSettingsPage() {
会商并发与收口
</h3>
<p className="text-muted-foreground text-sm">
配置第二步的全站并发名额、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
配置第二步的全站并发名额、房间租约、最长占用时间和总控强制收口轮数。所有账号共用房间名额;仅打开页面或查看历史记录不占用。
</p>
</div>
@@ -187,6 +206,39 @@ export function RoundtableRoomSettingsPage() {
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
<Clock3Icon className="size-4" />
房间租约
</span>
<p className="text-muted-foreground text-sm">
默认 5 分钟,可改为 1 到 60 分钟。页面大约每 15 秒续一次;超过这个时间没有续上,房间会释放。保存后,下一次准入和心跳立即按新时长续租。它不会延长单个房间的最长占用时间。
</p>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={1}
max={60}
value={leaseMinutes}
disabled={isError || update.isPending}
onChange={(event) => setLeaseMinutes(event.target.value)}
className="border-input bg-background h-9 w-24 rounded-md border px-3 text-sm"
aria-label="会商房间租约分钟数"
/>
<span className="text-muted-foreground text-sm">分钟</span>
</div>
</label>
<Button
type="button"
size="sm"
disabled={isError || update.isPending}
onClick={saveLeaseMinutes}
>
保存租约
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-4 rounded-lg border p-4">
<label className="space-y-1">
<span className="flex items-center gap-2 font-medium">
@@ -1,8 +1,9 @@
"use client";
import {
BellIcon,
BarChart3Icon,
BrainIcon,
BookPlusIcon,
CpuIcon,
DatabaseIcon,
ArchiveIcon,
@@ -32,6 +33,7 @@ import { AccountSettingsPage } from "@/components/workspace/settings/account-set
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { AppIdentitySettingsPage } from "@/components/workspace/settings/app-identity-settings-page";
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
import { CallMetricsSettingsPage } from "@/components/workspace/settings/call-metrics-settings-page";
import { ModelPromptSettingsPage } from "@/components/workspace/settings/model-prompt-settings-page";
import { WorkerPoolSettingsPage } from "@/components/workspace/settings/worker-pool-settings-page";
import { ApiReadCacheSettingsPage } from "@/components/workspace/settings/api-read-cache-settings-page";
@@ -39,6 +41,7 @@ import { ChatArchiveSettingsPage } from "@/components/workspace/settings/chat-ar
import { RoundtableRoomSettingsPage } from "@/components/workspace/settings/roundtable-room-settings-page";
import { RetryKeepPartialSettingsPage } from "@/components/workspace/settings/retry-keep-partial-settings-page";
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
import { KnowledgeBatchCreateSettingsPage } from "@/components/workspace/settings/knowledge-batch-create-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
@@ -60,9 +63,11 @@ type SettingsSection =
| "chat-archive"
| "roundtable-rooms"
| "knowledge-retrieval"
| "knowledge-batch-create"
| "appIdentity"
| "memory"
| "fileWriting"
| "callMetrics"
| "retryKeepPartial"
| "uploads"
| "tools"
@@ -115,10 +120,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
{ id: "appIdentity", label: "系统名称", icon: TypeIcon },
{ id: "memory", label: "问答记忆", icon: BrainIcon },
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
{ id: "callMetrics", label: "调用统计", icon: BarChart3Icon },
{ id: "retryKeepPartial", label: "保留中间正文", icon: HistoryIcon },
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
]
: []),
{ id: "knowledge-batch-create", label: "批量新增知识库", icon: BookPlusIcon },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -210,6 +217,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "knowledge-retrieval" && isAdmin && (
<KnowledgeRetrievalSettingsPage />
)}
{activeSection === "knowledge-batch-create" && (
<KnowledgeBatchCreateSettingsPage />
)}
{activeSection === "appIdentity" && isAdmin && (
<AppIdentitySettingsPage />
)}
@@ -219,6 +229,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "fileWriting" && isAdmin && (
<WriteFileCompatibilitySettingsPage />
)}
{activeSection === "callMetrics" && isAdmin && (
<CallMetricsSettingsPage />
)}
{activeSection === "retryKeepPartial" && isAdmin && (
<RetryKeepPartialSettingsPage />
)}
@@ -33,6 +33,11 @@ import {
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
DateRangePicker,
type DateRangePickerProps,
type DateRangeValue,
} from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -74,6 +79,7 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { filterSkillsByCreatedAt } from "@/core/skill-created-at-filter";
import {
useDeleteBuiltinSkill,
useDeleteCustomSkill,
@@ -99,6 +105,7 @@ import {
import {
usePublishSquares,
useSkillCompressionSettings,
useSkillVersioningSettings,
useViewMode,
} from "@/core/system-settings/hooks";
import {
@@ -155,6 +162,19 @@ type SkillSort =
| "name_asc"
| "name_desc";
function formatLocalCalendarDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function recentCalendarRange(days: number, now = new Date()): [string, string] {
const start = new Date(now);
start.setDate(start.getDate() - Math.max(1, days) + 1);
return [formatLocalCalendarDate(start), formatLocalCalendarDate(now)];
}
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
classNames: {
@@ -205,6 +225,7 @@ function SkillSettingsList({
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
const [sort, setSort] = useState<SkillSort>("time_desc");
const [createdAtRange, setCreatedAtRange] = useState<DateRangeValue>([]);
const [page, setPage] = useState(1);
// Publisher filter: click a skill's 发布者 to keep only that user's skills.
// {id} is owner_user_id; {label} the desensitized display name.
@@ -227,6 +248,19 @@ function SkillSettingsList({
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const createdAtPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
return {
今天: recentCalendarRange(1, now),
最近7天: recentCalendarRange(7, now),
最近30天: recentCalendarRange(30, now),
本月: [
formatLocalCalendarDate(monthStart),
formatLocalCalendarDate(now),
],
};
}, []);
// Name + tag filtering is resolved server-side via the /api/skills query.
// A 岗位 (position) no longer *restricts* the list — it grants skills into the
@@ -238,8 +272,16 @@ function SkillSettingsList({
tagIds,
sort,
scope: isAdmin ? undefined : "mine",
// The same catalogue query is also used while chatting. Always refetch
// when Skill Management mounts so a just-invoked skill's counter is fresh.
refetchOnMount: "always",
});
const skillsInCreatedAtRange = useMemo(
() => filterSkillsByCreatedAt(skills, createdAtRange),
[skills, createdAtRange],
);
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
@@ -248,7 +290,7 @@ function SkillSettingsList({
const mine: Skill[] = [];
const stale: Skill[] = [];
const archived: Skill[] = [];
for (const skill of skills) {
for (const skill of skillsInCreatedAtRange) {
const state = skill.state ?? "active";
const isCustom = skill.category === "custom";
const isOwnedByMe =
@@ -279,7 +321,7 @@ function SkillSettingsList({
staleVisible: stale,
archivedVisible: archived,
};
}, [skills, currentUserId]);
}, [skillsInCreatedAtRange, currentUserId]);
// "mine" tab shows my own skills; every other tab value is a square id whose
// public skills come from the published pool.
@@ -334,7 +376,7 @@ function SkillSettingsList({
}, [page, totalPages]);
useEffect(() => {
setPage(1);
}, [search, tagIds, sort, publisher?.id, filter]);
}, [search, tagIds, sort, publisher?.id, filter, createdAtRange]);
// If the active square tab disappears (admin removed that square), fall back
// to "我的" so the page never points at a dead tab.
@@ -562,7 +604,20 @@ function SkillSettingsList({
tagIds={tagIds}
onTagIdsChange={setTagIds}
actions={
<div className="flex items-center gap-2">
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<DateRangePicker
className="w-full sm:w-[300px]"
value={createdAtRange}
valueType="YYYY-MM-DD"
format="YYYY-MM-DD"
placeholder={["开始日期", "结束日期"]}
presets={createdAtPresets}
presetsPlacement="bottom"
clearable
rangeInputProps={{ label: "创建时间" }}
popupProps={{ overlayClassName: "skill-created-at-range-popup" }}
onChange={setCreatedAtRange}
/>
<ViewModeToggle value={viewMode} onChange={setViewMode} />
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
<SelectTrigger
@@ -749,8 +804,8 @@ export function SkillRow({
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
toast.success(
enabled
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
? t.settings.skills.onlineSuccess
: t.settings.skills.offlineSuccess,
);
setConfirmOfflineOpen(false);
} catch (err) {
@@ -1076,8 +1131,8 @@ export function SkillRow({
}}
title={
isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction
}
>
{setEnabled.isPending ? (
@@ -1088,8 +1143,8 @@ export function SkillRow({
<PowerOff className="size-3.5" />
)}
{isOffline
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
? t.settings.skills.onlineAction
: t.settings.skills.offlineAction}
</Button>
)}
<Button
@@ -1322,9 +1377,9 @@ export function SkillRow({
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
<DialogDescription>
{t.settings.skills.offlineConfirmDescription.replace(
{t.settings.skills.offlineConfirmDescription.replace(
"{name}",
desensitize(skill.name),
)}
@@ -1345,7 +1400,7 @@ export function SkillRow({
>
{setEnabled.isPending
? t.common.loading
: t.settings.skills.offlineAction}
: t.settings.skills.offlineAction}
</Button>
</DialogFooter>
</DialogContent>
@@ -1418,6 +1473,7 @@ function InstallSkillDialog({
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const validateMutation = useValidateSkillUpload();
const installMutation = useInstallSkillUpload();
const { data: skillVersioning } = useSkillVersioningSettings();
const setNameZhMutation = useSetSkillNameZh();
const generateNameZhMutation = useGenerateSkillNameZh();
@@ -1506,10 +1562,18 @@ function InstallSkillDialog({
);
const handleInstall = useCallback(
async (overwrite: boolean) => {
async (
overwrite: boolean,
installMode?: "iterate" | "overwrite",
) => {
if (!file) return;
try {
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
const result = await installMutation.mutateAsync({
file,
overwrite,
targetCategory,
installMode,
});
toast.success(
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
"{name}",
@@ -1564,6 +1628,10 @@ function InstallSkillDialog({
const canInstall = !!validation && !validationError && validation.can_install_as_is;
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
const showIterationChoices =
skillVersioning?.enabled === true &&
targetCategory === "custom" &&
ownCustomCanOverwrite;
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
const unclaimedCustomConflict =
@@ -1789,6 +1857,12 @@ function InstallSkillDialog({
</span>
</div>
)}
{showIterationChoices && (
<div className="bg-muted/40 mt-1 rounded-md border px-3 py-2 text-xs leading-relaxed">
<span className="font-medium">请选择更新方式:</span>
迭代会保留旧版本并把本次上传设为最新版;直接覆盖会清除旧版本链,从本次上传重新作为第 1 版记录。两种方式都不会清零技能调用次数。
</div>
)}
{isPublicConflict && (
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
@@ -1810,27 +1884,45 @@ function InstallSkillDialog({
>
{dialog.cancel}
</Button>
{canOverwrite && (
{showIterationChoices && (
<Button
variant="destructive"
onClick={() => handleInstall(true)}
onClick={() => void handleInstall(true, "iterate")}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{dialog.replace}
迭代为新版本
</Button>
)}
{canOverwrite && (
<Button
variant="destructive"
onClick={() =>
void handleInstall(
true,
showIterationChoices ? "overwrite" : undefined,
)
}
disabled={installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{showIterationChoices ? "直接覆盖" : dialog.replace}
</Button>
)}
{!showIterationChoices && (
<Button
onClick={() => void handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
)}
<Button
onClick={() => handleInstall(false)}
disabled={!canInstall || installMutation.isPending}
>
{installMutation.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{installMutation.isPending ? dialog.installing : dialog.install}
</Button>
</DialogFooter>
</>
)}
@@ -7,14 +7,17 @@ import {
FilesIcon,
FileTextIcon,
GlobeIcon,
HistoryIcon,
Loader2,
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
UploadCloudIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Dialog as TDesignDialog } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -38,15 +41,23 @@ import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useGenerateSkillNameZh,
useActivateSkillVersion,
useInstallSkillUpload,
useSetSkillAlwaysOn,
useSetSkillDetail,
useSetSkillNameZh,
useSkill,
useSkillContent,
useSkillFiles,
useSkillVersions,
useValidateSkillUpload,
} from "@/core/skills/hooks";
import { useSkillCompressionSettings } from "@/core/system-settings/hooks";
import {
useSkillCompressionSettings,
useSkillVersioningSettings,
} from "@/core/system-settings/hooks";
import type { Skill } from "@/core/skills/type";
import { formatDateTime } from "@/core/utils/datetime";
import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
@@ -109,7 +120,11 @@ export function SkillDetailDialog({
const setNameZh = useSetSkillNameZh();
const generateNameZh = useGenerateSkillNameZh();
const setAlwaysOn = useSetSkillAlwaysOn();
const activateVersion = useActivateSkillVersion();
const validateIteration = useValidateSkillUpload();
const installIteration = useInstallSkillUpload();
const { data: compression } = useSkillCompressionSettings();
const { data: versioning } = useSkillVersioningSettings();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
@@ -118,10 +133,15 @@ export function SkillDetailDialog({
// Optional model override for the "AI 生成" button (null = configured default).
const [nameZhModel, setNameZhModel] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"detail" | "display" | "source" | "files" | null
"overview" | "detail" | "display" | "source" | "files" | "versions" | null
>(null);
const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const [versionToActivate, setVersionToActivate] = useState<number | null>(null);
const [iterationFile, setIterationFile] = useState<File | null>(null);
const iterationFileRef = useRef<HTMLInputElement | null>(null);
const confirmationOpen =
versionToActivate !== null || iterationFile !== null;
const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
@@ -132,6 +152,12 @@ export function SkillDetailDialog({
if (!skill) return false;
return isOwner || getStoredAuth()?.system_role === "admin";
}, [skill, isOwner]);
const canUseVersions =
versioning?.enabled === true &&
skill?.category === "custom" &&
!skill.builtin &&
canManage;
const canIterate = canUseVersions && isOwner;
const {
data: contentData,
isLoading: isContentLoading,
@@ -144,6 +170,12 @@ export function SkillDetailDialog({
isLoading: isFilesLoading,
error: filesError,
} = useSkillFiles(filesEnabled ? skillName : null);
const versionsEnabled = open && canUseVersions && activeTab === "versions";
const {
data: versionsData,
isLoading: isVersionsLoading,
error: versionsError,
} = useSkillVersions(versionsEnabled ? skillName : null, versionsEnabled);
useEffect(() => {
setEditing(false);
@@ -151,17 +183,23 @@ export function SkillDetailDialog({
setActiveTab(null);
setShowRawSource(false);
setOpenedFilePath(null);
setVersionToActivate(null);
setIterationFile(null);
}, [open, skillName]);
useEffect(() => {
setNameZhDraft(skill?.name_zh ?? "");
}, [skill?.name, skill?.name_zh]);
useEffect(() => {
if (!canUseVersions && activeTab === "versions") setActiveTab("overview");
}, [activeTab, canUseVersions]);
const skillBody = useMemo(() => {
return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]);
const defaultTab: "detail" = "detail";
const defaultTab: "overview" = "overview";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
@@ -218,11 +256,80 @@ export function SkillDetailDialog({
toast.success("Copied SKILL.md content");
}
function handleActivateVersion(version: number) {
if (!skill || version === versionsData?.active_version) return;
setVersionToActivate(version);
}
async function confirmActivateVersion() {
if (!skill || versionToActivate === null) return;
try {
await activateVersion.mutateAsync({
skillName: skill.name,
version: versionToActivate,
});
toast.success(`已切换到 v${versionToActivate}`);
setVersionToActivate(null);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "版本切换失败");
}
}
async function handleIterationFile(file: File | undefined) {
if (!file || !skill || !canIterate) return;
try {
const validation = await validateIteration.mutateAsync({
file,
targetCategory: "custom",
});
if (
validation.skill_name !== skill.name ||
validation.conflict_kind !== "custom" ||
!validation.owned_by_me ||
!validation.can_overwrite
) {
throw new Error(`请选择与当前技能同名的安装包:${skill.name}`);
}
setIterationFile(file);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
} finally {
if (iterationFileRef.current) iterationFileRef.current.value = "";
}
}
async function confirmIteration() {
if (!iterationFile) return;
try {
await installIteration.mutateAsync({
file: iterationFile,
overwrite: true,
targetCategory: "custom",
installMode: "iterate",
});
toast.success("已迭代为新版本");
setIterationFile(null);
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "技能迭代失败");
}
}
function handleDetailOpenChange(nextOpen: boolean) {
// TDesign renders its modal through a portal. While it is open, Radix sees
// clicks inside that portal as interactions outside this detail dialog.
// Keep the parent mounted until the nested confirmation has finished.
if (!nextOpen && confirmationOpen) return;
onOpenChange(nextOpen);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleDetailOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
onInteractOutside={(event) => {
if (confirmationOpen) event.preventDefault();
}}
style={{
width: "min(48rem, calc(100vw - 2rem))",
maxWidth: "none",
@@ -345,8 +452,10 @@ export function SkillDetailDialog({
)}
</div>
)}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description ? desensitize(skill.description) : "-"}
<DialogDescription className="sr-only">
{skill?.description
? desensitize(skill.description)
: tx.introEmpty}
</DialogDescription>
</div>
</div>
@@ -368,7 +477,11 @@ export function SkillDetailDialog({
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
<TabsList className="h-8">
<TabsList className="h-8 min-w-0 max-w-full overflow-x-auto">
<TabsTrigger value="overview" className="text-xs">
<SparklesIcon className="mr-1 size-3.5" />
{tx.introSectionTitle}
</TabsTrigger>
<TabsTrigger value="detail" className="text-xs">
<FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle}
@@ -385,9 +498,45 @@ export function SkillDetailDialog({
{tx.filesSectionTitle}
</TabsTrigger>
)}
{canUseVersions && (
<TabsTrigger value="versions" className="text-xs">
<HistoryIcon className="mr-1 size-3.5" />
版本
</TabsTrigger>
)}
</TabsList>
<div className="flex shrink-0 items-center gap-1">
{canIterate && (
<>
<input
ref={iterationFileRef}
type="file"
accept=".zip,.skill,application/zip"
className="hidden"
onChange={(event) =>
void handleIterationFile(event.currentTarget.files?.[0])
}
/>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={
validateIteration.isPending ||
installIteration.isPending
}
onClick={() => iterationFileRef.current?.click()}
>
{validateIteration.isPending || installIteration.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<UploadCloudIcon className="size-3.5" />
)}
迭代新版本
</Button>
</>
)}
{canManage && compression?.enabled && skill && (
<div
className="mr-1 flex items-center gap-1.5"
@@ -460,6 +609,25 @@ export function SkillDetailDialog({
</div>
</div>
<TabsContent
value="overview"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{skill.description ? (
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
<p className="text-sm leading-7 whitespace-pre-wrap">
{desensitize(skill.description)}
</p>
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<SparklesIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.introEmpty}</p>
</div>
)}
</TabsContent>
<TabsContent
value="detail"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
@@ -554,6 +722,80 @@ export function SkillDetailDialog({
</TabsContent>
)}
{canUseVersions && (
<TabsContent
value="versions"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
<div className="mb-4 rounded-lg border bg-muted/30 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
版本切换只替换当前生效的完整技能包,历史版本不会进入大模型上下文;技能名称和累计调用次数保持不变。
</div>
{isVersionsLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : versionsError ? (
<div className="text-destructive py-6 text-sm">
{versionsError instanceof Error
? versionsError.message
: String(versionsError)}
</div>
) : versionsData?.versions.length ? (
<div className="space-y-3">
{versionsData.versions.map((version) => (
<div
key={version.version}
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-semibold">
v{version.version}
</span>
{version.active && (
<Badge className="text-[11px]">当前生效</Badge>
)}
<span className="text-muted-foreground text-xs">
{formatDateTime(version.created_at)}
</span>
</div>
<p className="mt-1.5 text-sm">
{version.summary || "技能内容更新"}
</p>
<p className="text-muted-foreground mt-1 font-mono text-[11px]">
{version.sha256.slice(0, 12)}
{version.based_on_version
? ` · 基于 v${version.based_on_version}`
: ""}
</p>
</div>
{!version.active && (
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={activateVersion.isPending}
onClick={() => void handleActivateVersion(version.version)}
>
{activateVersion.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
切换到此版本
</Button>
)}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-10 text-center text-sm">
暂无版本记录
</div>
)}
</TabsContent>
)}
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
@@ -597,6 +839,59 @@ export function SkillDetailDialog({
if (!next) setOpenedFilePath(null);
}}
/>
<TDesignDialog
visible={versionToActivate !== null}
header="切换技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定切换", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: activateVersion.isPending }}
confirmLoading={activateVersion.isPending}
closeBtn={!activateVersion.isPending}
closeOnEscKeydown={!activateVersion.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onCancel={() => {
if (!activateVersion.isPending) setVersionToActivate(null);
}}
onConfirm={() => void confirmActivateVersion()}
>
<p className="text-sm leading-6 text-muted-foreground">
确认切换到 v{versionToActivate ?? ""}?切换后大模型只会看到该版本,技能调用次数保持不变。
</p>
</TDesignDialog>
<TDesignDialog
visible={iterationFile !== null}
header="迭代技能版本"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "确定迭代", theme: "primary" }}
cancelBtn={{ content: "取消", disabled: installIteration.isPending }}
confirmLoading={installIteration.isPending}
closeBtn={!installIteration.isPending}
closeOnEscKeydown={!installIteration.isPending}
closeOnOverlayClick={false}
destroyOnClose
onClose={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onCancel={() => {
if (!installIteration.isPending) setIterationFile(null);
}}
onConfirm={() => void confirmIteration()}
>
<div className="space-y-2 text-sm leading-6 text-muted-foreground">
<p>确认将此安装包迭代为新版本?当前版本会完整保留,技能调用次数保持不变。</p>
<p className="truncate text-xs" title={iterationFile?.name}>
安装包:{iterationFile?.name}
</p>
</div>
</TDesignDialog>
</>
);
}
@@ -4,18 +4,18 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
export function ThreadTitle({
className,
threadId,
thread,
}: {
export function ThreadTitle({
className,
threadId,
thread,
}: {
className?: string;
threadId: string;
thread: BaseStream<AgentThreadState>;
@@ -51,9 +51,9 @@ export function ThreadTitle({
if (!thread.values?.title) {
return null;
}
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
const visibleTitle = desensitize(displayTitle);
return (
<FlipDisplay uniqueKey={threadId} className={cn("min-w-0 w-full", className)}>
<span className="block truncate" title={visibleTitle}>
{visibleTitle}
</span>
@@ -5,6 +5,8 @@ import { useMemo } from "react";
import { useI18n } from "@/core/i18n/hooks";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { GeneralQaPageDescription } from "@/core/system-settings/api";
import { cn } from "@/lib/utils";
import { AuroraText } from "../ui/aurora-text";
@@ -12,11 +14,15 @@ import { AuroraText } from "../ui/aurora-text";
export function Welcome({
className,
mode,
pageDescription,
}: {
className?: string;
mode?: "ultra" | "pro" | "thinking" | "flash";
/** The actual interface mode is resolved by ChatPage. */
pageDescription?: GeneralQaPageDescription;
}) {
const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
const desensitizeText = useDisplayDesensitizer();
const searchParams = useSearchParams();
const isUltra = useMemo(() => mode === "ultra", [mode]);
@@ -26,6 +32,9 @@ export function Welcome({
}
return ["var(--color-foreground)"];
}, [isUltra]);
const configured = pageDescription ?? pageDescriptions?.general_qa.full;
const normalTitle = configured?.welcome_title || t.welcome.greeting;
const normalDescription = configured?.welcome_description || "";
return (
<div
className={cn(
@@ -38,7 +47,7 @@ export function Welcome({
t.welcome.createYourOwnSkill
) : (
<div className="flex items-center">
<AuroraText colors={colors}>{desensitizeText(t.welcome.greeting)}</AuroraText>
<AuroraText colors={colors}>{desensitizeText(normalTitle)}</AuroraText>
</div>
)}
</div>
@@ -53,15 +62,11 @@ export function Welcome({
)} */}
</div>
) : (
<div className="text-muted-foreground text-sm">
{/* {t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre">
{t.welcome.description}
</pre>
) : (
<p>{t.welcome.description}</p>
)} */}
</div>
normalDescription ? (
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{desensitizeText(normalDescription)}
</p>
) : null
)}
</div>
);
@@ -10,6 +10,7 @@ import {
Compass,
CpuIcon,
DatabaseZapIcon,
FileTextIcon,
GaugeIcon,
HistoryIcon,
ListTreeIcon,
@@ -318,6 +319,16 @@ export function WorkspaceNavMenu() {
模型配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/admin/page-descriptions")
}
>
<FileTextIcon />
页面描述配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/stream-truncations")}
+58
View File
@@ -94,6 +94,33 @@ export interface AdminLeaderboardDimension {
last_seen_at: string;
}
export type AdminLeaderboardDetailKind = "skill" | "tool" | "agent";
export interface AdminLeaderboardDetailItem {
name: string;
count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailSummary {
count: number;
item_count: number;
user_count: number;
total_tokens: number;
avg_duration_ms: number;
}
export interface AdminLeaderboardDetailResponse {
kind: AdminLeaderboardDetailKind;
range_days: number;
since: string;
until: string;
summary: AdminLeaderboardDetailSummary;
items: AdminLeaderboardDetailItem[];
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
@@ -248,6 +275,37 @@ export async function getUserLeaderboard(
return result;
}
export async function getUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind,
range: {
since: string;
until: string;
days?: number;
},
limit = 500,
): Promise<AdminLeaderboardDetailResponse> {
const params = new URLSearchParams({
kind,
days: String(range.days ?? 30),
since: range.since,
until: range.until,
limit: String(limit),
});
const url = `${base()}/leaderboard/details?${params}`;
const result = await parse<AdminLeaderboardDetailResponse>(
await apiFetch(url),
"Failed to load leaderboard details",
);
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-管理后台-查询排行榜维度详情",
url,
reqParam: { kind, ...range, limit },
returnParam: { itemCount: result.items.length },
});
return result;
}
/**
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
* real-time records are included, then return the leaderboard for `range`.
+26
View File
@@ -8,6 +8,7 @@ import {
generateUserLeaderboardReport,
getUserMemory,
getUserLeaderboard,
getUserLeaderboardDetails,
refreshTodayLeaderboard,
listUserQa,
listUserThreadMessages,
@@ -16,6 +17,7 @@ import {
updateAdminV1Fact,
updateAdminV1Section,
} from "./api";
import type { AdminLeaderboardDetailKind } from "./api";
import {
cancelActiveRun,
cancelAllActiveRuns,
@@ -131,6 +133,30 @@ export function useUserLeaderboard(
});
}
/** Expanded, fresh detail data shown from a leaderboard card's “more” drawer. */
export function useUserLeaderboardDetails(
kind: AdminLeaderboardDetailKind | null,
range: {
since: string;
until: string;
days?: number;
},
enabled: boolean,
) {
return useQuery({
queryKey: [
"admin-activity",
"leaderboard-details",
kind,
range.since,
range.until,
],
queryFn: () => getUserLeaderboardDetails(kind!, range),
enabled: enabled && !!kind,
refetchOnWindowFocus: false,
});
}
/**
* Force-rebuild today's snapshot, then prime + invalidate the matching
* leaderboard query so the page shows the freshly recomputed numbers.
+11 -1
View File
@@ -53,7 +53,17 @@ export async function downloadArtifactFile({
isMock?: boolean;
publicShareCode?: string;
}): Promise<void> {
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
const isHtmlArtifact = /\.(?:html?|xhtml)$/i.test(getFileName(filepath));
const url = urlOfArtifact({
filepath,
threadId,
isMock,
publicShareCode,
download: true,
// Older deployments and mock endpoints safely ignore this query and return
// the original HTML, which preserves backwards compatibility.
offlineBundle: isHtmlArtifact && !isMock,
});
const res = await apiFetch(url);
if (!res.ok) {
throw new Error(await errorMessageFromResponse(res));
+10 -3
View File
@@ -5,28 +5,35 @@ export function urlOfArtifact({
filepath,
threadId,
download = false,
offlineBundle = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
download?: boolean;
offlineBundle?: boolean;
isMock?: boolean;
publicShareCode?: string;
}) {
const query = new URLSearchParams();
if (download) query.set("download", "true");
if (offlineBundle) query.set("offline_bundle", "true");
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : "";
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
)}/files${normalized}${suffix}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${suffix}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearHeaderTitlePreview,
getHeaderTitlePreview,
setHeaderTitlePreview,
subscribeHeaderTitlePreview,
} from "./header-title-preview";
test("publishes live header title previews and clears unsaved values", () => {
clearHeaderTitlePreview();
let updates = 0;
const unsubscribe = subscribeHeaderTitlePreview(() => {
updates += 1;
});
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.deepEqual(getHeaderTitlePreview(), {
mode: "fixed",
fontSize: 34,
});
assert.equal(updates, 1);
setHeaderTitlePreview({ mode: "fixed", fontSize: 34 });
assert.equal(updates, 1);
clearHeaderTitlePreview();
assert.equal(getHeaderTitlePreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,35 @@
export type HeaderTitlePreview = {
mode: "adaptive" | "fixed";
fontSize: number;
};
let currentPreview: HeaderTitlePreview | null = null;
const listeners = new Set<() => void>();
export function getHeaderTitlePreview(): HeaderTitlePreview | null {
return currentPreview;
}
export function subscribeHeaderTitlePreview(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setHeaderTitlePreview(preview: HeaderTitlePreview): void {
if (
currentPreview?.mode === preview.mode &&
currentPreview.fontSize === preview.fontSize
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearHeaderTitlePreview(): void {
if (currentPreview === null) {
return;
}
currentPreview = null;
listeners.forEach((listener) => listener());
}
@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearLightRedNavigationPreview,
getLightRedNavigationPreview,
setLightRedNavigationPreview,
subscribeLightRedNavigationPreview,
} from "./light-red-navigation-preview";
test("publishes temporary red navigation image URLs and clears them", () => {
clearLightRedNavigationPreview();
let updates = 0;
const unsubscribe = subscribeLightRedNavigationPreview(() => {
updates += 1;
});
setLightRedNavigationPreview({
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.deepEqual(getLightRedNavigationPreview(), {
headerBackgroundUrl: "blob:header",
navActiveUrl: "blob:active",
});
assert.equal(updates, 1);
clearLightRedNavigationPreview();
assert.equal(getLightRedNavigationPreview(), null);
assert.equal(updates, 2);
unsubscribe();
});
@@ -0,0 +1,37 @@
export interface LightRedNavigationPreview {
headerBackgroundUrl: string | null;
navActiveUrl: string | null;
}
type Listener = () => void;
let currentPreview: LightRedNavigationPreview | null = null;
const listeners = new Set<Listener>();
export function getLightRedNavigationPreview() {
return currentPreview;
}
export function subscribeLightRedNavigationPreview(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setLightRedNavigationPreview(
preview: LightRedNavigationPreview,
) {
if (
currentPreview?.headerBackgroundUrl === preview.headerBackgroundUrl &&
currentPreview.navActiveUrl === preview.navActiveUrl
) {
return;
}
currentPreview = { ...preview };
listeners.forEach((listener) => listener());
}
export function clearLightRedNavigationPreview() {
if (currentPreview === null) return;
currentPreview = null;
listeners.forEach((listener) => listener());
}
+3 -1
View File
@@ -498,7 +498,7 @@ export const enUS: Translations = {
badgePublishedTitle: "Visible to all users",
badgeUnpublished: "Unpublished",
badgeUnpublishedTitle: "Not published yet — only visible to its owner",
badgeOffline: "Offline",
badgeOffline: "Offline",
badgeOfflineTitle: "Retained in the system but unavailable to models",
badgeStale: "Stale",
badgeStaleTitle: "Idle for a long time — flagged by the curator",
@@ -541,6 +541,8 @@ export const enUS: Translations = {
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
introSectionTitle: "Overview",
introEmpty: "No skill overview yet.",
detailSectionTitle: "Details",
detailEmpty: "No details yet.",
detailEditAction: "Edit details",
@@ -471,6 +471,8 @@ export interface Translations {
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
introSectionTitle: string;
introEmpty: string;
detailSectionTitle: string;
detailEmpty: string;
detailEditAction: string;
+4 -2
View File
@@ -477,8 +477,8 @@ export const zhCN: Translations = {
badgePublishedTitle: "该技能已对所有用户可见",
badgeUnpublished: "未发布",
badgeUnpublishedTitle: "该技能尚未发布,仅所有者可见",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeOffline: "已下线",
badgeOfflineTitle: "技能仍保留在系统中,但不会提供给模型调用",
badgeStale: "过时",
badgeStaleTitle: "长期未使用,已被策展程序标记",
badgeArchived: "归档",
@@ -519,6 +519,8 @@ export const zhCN: Translations = {
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
introSectionTitle: "简介",
introEmpty: "暂无技能简介。",
detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情",
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
// @ts-expect-error Node's type-stripping runner requires the explicit suffix.
import { filterSkillsByCreatedAt } from "./skill-created-at-filter.ts";
const skills = [
{ name: "before", created_at: "2026-10-31T23:59:59" },
{ name: "start", created_at: "2026-11-01T00:00:00" },
{ name: "middle", created_at: "2026-11-05T12:00:00" },
{ name: "end", created_at: "2026-11-07T23:59:59" },
{ name: "after", created_at: "2026-11-08T00:00:00" },
{ name: "unknown", created_at: null },
];
test("keeps the original list when no complete range is selected", () => {
assert.equal(filterSkillsByCreatedAt(skills, []), skills);
assert.equal(filterSkillsByCreatedAt(skills, ["2026-11-01"]), skills);
});
test("filters creation time inclusively by local calendar day", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-01", "2026-11-07"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
test("accepts a reversed range without changing its meaning", () => {
assert.deepEqual(
filterSkillsByCreatedAt(skills, ["2026-11-07", "2026-11-01"]).map(
(skill) => skill.name,
),
["start", "middle", "end"],
);
});
@@ -0,0 +1,42 @@
import type { Skill } from "./skills/type";
export type SkillCreatedAtRange = readonly (string | number | Date)[];
function localDayStart(value: string | number | Date) {
let date: Date;
if (typeof value === "string") {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
date = dateOnly
? new Date(
Number(dateOnly[1]),
Number(dateOnly[2]) - 1,
Number(dateOnly[3]),
)
: new Date(value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return null;
date.setHours(0, 0, 0, 0);
return date.getTime();
}
/** Filter skills by an inclusive local-calendar creation date range. */
export function filterSkillsByCreatedAt<T extends Pick<Skill, "created_at">>(
skills: T[],
range: SkillCreatedAtRange,
): T[] {
if (range.length < 2) return skills;
const first = localDayStart(range[0]);
const second = localDayStart(range[1]);
if (first === null || second === null) return skills;
const start = Math.min(first, second);
const endDate = new Date(Math.max(first, second));
endDate.setHours(23, 59, 59, 999);
const end = endDate.getTime();
return skills.filter((skill) => {
if (!skill.created_at) return false;
const createdAt = new Date(skill.created_at).getTime();
return !Number.isNaN(createdAt) && createdAt >= start && createdAt <= end;
});
}
+50 -1
View File
@@ -159,6 +159,24 @@ export interface InstallSkillResponse {
message: string;
overwritten?: boolean;
}
export interface SkillVersionEntry {
version: number;
sha256: string;
created_at: string;
created_by?: string | null;
action: string;
summary?: string | null;
based_on_version?: number | null;
active: boolean;
}
export interface SkillVersionsResponse {
enabled: boolean;
skill_name: string;
active_version?: number | null;
versions: SkillVersionEntry[];
}
export interface PreviewSkillInstallRequest {
thread_id: string;
@@ -271,13 +289,18 @@ export async function validateSkillUpload(
export async function installSkillUpload(
file: File,
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
options: {
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
} = {},
): Promise<InstallSkillResponse> {
const form = new FormData();
form.append("file", file, file.name);
const params = new URLSearchParams();
if (options.overwrite) params.set("overwrite", "true");
if (options.targetCategory) params.set("target_category", options.targetCategory);
if (options.installMode) params.set("install_mode", options.installMode);
const query = params.size > 0 ? `?${params.toString()}` : "";
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
@@ -289,6 +312,32 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export async function getSkillVersions(
skillName: string,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill versions");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function activateSkillVersion(
skillName: string,
version: number,
): Promise<SkillVersionsResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/versions/${version}/activate`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to activate skill version");
}
return response.json() as Promise<SkillVersionsResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
+52 -4
View File
@@ -10,6 +10,7 @@ import {
deleteCustomSkill,
deleteSkillFile,
duplicateSkill,
activateSkillVersion,
enableSkill,
generateMissingSkillNameZh,
generateSkillNameZh,
@@ -17,6 +18,7 @@ import {
getSkillContent,
getSkillFileContent,
getSkillFiles,
getSkillVersions,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
@@ -41,17 +43,24 @@ import { loadAllSkillsAdmin, loadSkills } from ".";
// forever (observed as a "Maximum update depth exceeded" loop on NewAgentPage).
const EMPTY_SKILLS: Skill[] = [];
export function useSkills(params: ListSkillsParams & { enabled?: boolean } = {}) {
export function useSkills(
params: ListSkillsParams & {
enabled?: boolean;
refetchOnMount?: boolean | "always";
} = {},
) {
const search = params.search ?? "";
const tagIds = params.tagIds ?? [];
const sort = params.sort ?? "time_desc";
const scope = params.scope;
const enabled = params.enabled ?? true;
const refetchOnMount = params.refetchOnMount;
const { data, isLoading, error } = useQuery({
queryKey: ["skills", search, [...tagIds].sort(), sort, scope ?? "all"],
queryFn: () => loadSkills({ search, tagIds, sort, scope }),
enabled,
staleTime: 30_000,
refetchOnMount,
placeholderData: keepPreviousData,
});
return { skills: data ?? EMPTY_SKILLS, isLoading, error };
@@ -224,10 +233,49 @@ export function useValidateSkillUpload() {
export function useInstallSkillUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
installSkillUpload(file, { overwrite, targetCategory }),
onSuccess: () => {
mutationFn: ({ file, overwrite, targetCategory, installMode }: {
file: File;
overwrite?: boolean;
targetCategory?: "public" | "custom";
installMode?: "iterate" | "overwrite";
}) => installSkillUpload(file, { overwrite, targetCategory, installMode }),
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({
queryKey: ["skill-versions", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-content", result.skill_name],
});
void queryClient.invalidateQueries({
queryKey: ["skill-files", result.skill_name],
});
},
});
}
export function useSkillVersions(skillName: string | null, enabled: boolean) {
return useQuery({
queryKey: ["skill-versions", skillName],
queryFn: () => getSkillVersions(skillName!),
enabled: enabled && !!skillName,
});
}
export function useActivateSkillVersion() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, version }: { skillName: string; version: number }) =>
activateSkillVersion(skillName, version),
onSuccess: (data, { skillName }) => {
queryClient.setQueryData(["skill-versions", skillName], data);
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
},
});
}
+230 -3
View File
@@ -32,6 +32,86 @@ export interface StrategyProjectSettingsUpdate {
taiwan_strait_demo_enabled?: boolean;
}
export interface GeneralQaPageDescription {
welcome_title: string;
welcome_description: string;
input_placeholder: string;
}
export interface GeneralQaPageDescriptions {
full: GeneralQaPageDescription;
compact: GeneralQaPageDescription;
}
export interface PageHeaderDescription {
title: string;
description: string;
}
export interface AIWritingGuideCard {
title: string;
description: string;
}
export interface AIWritingPageDescription extends PageHeaderDescription {
guide_cards: AIWritingGuideCard[];
}
export interface PageDescriptionsSettings {
general_qa: GeneralQaPageDescriptions;
knowledge_base: PageHeaderDescription;
ai_writing: AIWritingPageDescription;
strategy_projects: PageHeaderDescription;
deep_research: PageHeaderDescription;
gongwen: PageHeaderDescription;
light_app_management: PageHeaderDescription;
light_app_list: PageHeaderDescription;
agents: PageHeaderDescription;
business_chains: PageHeaderDescription;
skills: PageHeaderDescription;
knowledge_management: PageHeaderDescription;
}
export type PageDescriptionsUpdate = PageDescriptionsSettings;
export async function loadPageDescriptionsSettings(): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
);
if (!response.ok) {
throw new Error(`无法读取页面描述配置(HTTP ${response.status})`);
}
return (await response.json()) as PageDescriptionsSettings;
}
export async function updatePageDescriptionsSettings(
body: PageDescriptionsUpdate,
): Promise<PageDescriptionsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/page-descriptions`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "保存页面描述配置失败" }));
throw new Error(detail.detail ?? "保存页面描述配置失败");
}
const data = (await response.json()) as PageDescriptionsSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-页面描述配置编辑",
url: "/api/system-settings/page-descriptions",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadStrategyProjectSettings(): Promise<StrategyProjectSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/strategy-projects`,
@@ -311,17 +391,33 @@ export interface HtmlRuntimeSettings {
map_tile_url: string;
}
export type AppIdentityHeaderTitleFontMode = "adaptive" | "fixed";
export interface AppIdentitySettings {
system_name: string;
header_title_font_mode: AppIdentityHeaderTitleFontMode;
header_title_font_size: number;
logo_url: string | null;
logo_version: string | null;
has_logo: boolean;
light_red_header_background_url: string | null;
light_red_header_background_version: string | null;
has_light_red_header_background: boolean;
light_red_nav_active_url: string | null;
light_red_nav_active_version: string | null;
has_light_red_nav_active: boolean;
}
export interface AppIdentityUpdate {
systemName: string;
headerTitleFontMode: AppIdentityHeaderTitleFontMode;
headerTitleFontSize: number;
logo?: File | null;
removeLogo?: boolean;
lightRedHeaderBackground?: File | null;
removeLightRedHeaderBackground?: boolean;
lightRedNavActive?: File | null;
removeLightRedNavActive?: boolean;
}
export interface LeaderboardSettings {
@@ -721,8 +817,22 @@ export async function updateAppIdentitySettings(
): Promise<AppIdentitySettings> {
const body = new FormData();
body.set("system_name", update.systemName);
body.set("header_title_font_mode", update.headerTitleFontMode);
body.set("header_title_font_size", String(update.headerTitleFontSize));
if (update.logo) body.set("logo", update.logo);
if (update.removeLogo) body.set("remove_logo", "true");
if (update.lightRedHeaderBackground) {
body.set("light_red_header_background", update.lightRedHeaderBackground);
}
if (update.removeLightRedHeaderBackground) {
body.set("remove_light_red_header_background", "true");
}
if (update.lightRedNavActive) {
body.set("light_red_nav_active", update.lightRedNavActive);
}
if (update.removeLightRedNavActive) {
body.set("remove_light_red_nav_active", "true");
}
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/app-identity`,
{ method: "PUT", body },
@@ -740,8 +850,17 @@ export async function updateAppIdentitySettings(
url: "/api/system-settings/app-identity",
reqParam: {
system_name: update.systemName,
header_title_font_mode: update.headerTitleFontMode,
header_title_font_size: update.headerTitleFontSize,
logo_name: update.logo?.name,
remove_logo: Boolean(update.removeLogo),
light_red_header_background_name:
update.lightRedHeaderBackground?.name,
remove_light_red_header_background: Boolean(
update.removeLightRedHeaderBackground,
),
light_red_nav_active_name: update.lightRedNavActive?.name,
remove_light_red_nav_active: Boolean(update.removeLightRedNavActive),
},
returnParam: data,
});
@@ -772,10 +891,16 @@ export async function deleteAppIdentitySettings(): Promise<AppIdentitySettings>
export function resolveAppIdentityLogoUrl(
logoUrl: string | null | undefined,
): string | undefined {
if (!logoUrl) return undefined;
if (/^https?:\/\//i.test(logoUrl)) return logoUrl;
return resolveAppIdentityAssetUrl(logoUrl);
}
export function resolveAppIdentityAssetUrl(
assetUrl: string | null | undefined,
): string | undefined {
if (!assetUrl) return undefined;
if (/^https?:\/\//i.test(assetUrl)) return assetUrl;
const base = getBackendBaseURL();
return `${base}${logoUrl.startsWith("/") ? "" : "/"}${logoUrl}`;
return `${base}${assetUrl.startsWith("/") ? "" : "/"}${assetUrl}`;
}
export async function loadHtmlRuntimeSettings(): Promise<HtmlRuntimeSettings> {
@@ -867,6 +992,10 @@ export interface SkillCompressionUpdate {
keep_index_in_prompt?: boolean;
}
export interface SkillVersioningSettings {
enabled: boolean;
}
export interface ModelConcurrencySettings {
enabled: boolean;
default_limit: number;
@@ -906,6 +1035,7 @@ export interface RoundtableRoomLimitSettings {
enabled: boolean;
max_rooms: number;
max_hold_minutes: number;
lease_minutes: number;
max_cycles: number;
write_file_compatibility_enabled: boolean;
}
@@ -914,6 +1044,7 @@ export interface RoundtableRoomLimitUpdate {
enabled?: boolean;
max_rooms?: number;
max_hold_minutes?: number;
lease_minutes?: number;
max_cycles?: number;
write_file_compatibility_enabled?: boolean;
}
@@ -1012,6 +1143,55 @@ export async function updateWriteFileCompatibilitySettings(
return data;
}
export interface CallMetricsSettings {
enabled: boolean;
}
export async function loadCallMetricsSettings(): Promise<CallMetricsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
);
if (!response.ok) {
throw new Error(`无法读取调用统计开关(HTTP ${response.status})`);
}
const data = (await response.json()) as CallMetricsSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-调用统计查看",
url: "/api/system-settings/call-metrics",
returnParam: data,
});
return data;
}
export async function updateCallMetricsSettings(
body: CallMetricsSettings,
): Promise<CallMetricsSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/call-metrics`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({
detail: "保存调用统计开关失败",
}));
throw new Error(detail.detail ?? "保存调用统计开关失败");
}
const data = (await response.json()) as CallMetricsSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-调用统计开关",
url: "/api/system-settings/call-metrics",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadConversationContextSettings(): Promise<ConversationContextSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/conversation-context`,
@@ -1155,6 +1335,25 @@ export async function loadSkillCompressionSettings(): Promise<SkillCompressionSe
return data;
}
export async function loadSkillVersioningSettings(): Promise<SkillVersioningSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
);
if (!response.ok) {
throw new Error(
`Failed to load skill versioning settings: ${response.status}`,
);
}
const data = (await response.json()) as SkillVersioningSettings;
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-系统设置-技能迭代查看",
url: "/api/system-settings/skill-versioning",
returnParam: data,
});
return data;
}
export async function loadModelConcurrencySettings(): Promise<ModelConcurrencySettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/model-concurrency`,
@@ -1230,6 +1429,34 @@ export async function updateSkillCompressionSettings(
return data;
}
export async function updateSkillVersioningSettings(
body: SkillVersioningSettings,
): Promise<SkillVersioningSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-versioning`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update skill versioning" }));
throw new Error(detail.detail ?? "Failed to update skill versioning");
}
const data = (await response.json()) as SkillVersioningSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-技能迭代编辑",
url: "/api/system-settings/skill-versioning",
reqParam: body,
returnParam: data,
});
return data;
}
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
export interface AgentModuleConfig {
key: string;
@@ -6,6 +6,7 @@ import {
loadAgentModuleConfig,
loadAssistantKnowledgeVisibility,
loadAppearanceSettings,
loadCallMetricsSettings,
loadCitationDisplaySettings,
loadConversationContextSettings,
loadRoundtableRoomLimitSettings,
@@ -15,6 +16,7 @@ import {
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadOrdinaryKnowledgeRetrievalSettings,
loadPageDescriptionsSettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
@@ -22,11 +24,13 @@ import {
loadPublishSquares,
loadRetryKeepPartialSettings,
loadSkillCompressionSettings,
loadSkillVersioningSettings,
loadStrategyProjectSettings,
loadWxCommonAgents,
loadWriteFileCompatibilitySettings,
updateAgentModuleConfig,
updateAppearanceSettings,
updateCallMetricsSettings,
updateCitationDisplaySettings,
updateConversationContextSettings,
updateRoundtableRoomLimitSettings,
@@ -36,6 +40,7 @@ import {
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateOrdinaryKnowledgeRetrievalSettings,
updatePageDescriptionsSettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
@@ -43,6 +48,7 @@ import {
updatePublishSquares,
updateRetryKeepPartialSettings,
updateSkillCompressionSettings,
updateSkillVersioningSettings,
updateStrategyProjectSettings,
updateWxCommonAgents,
updateWriteFileCompatibilitySettings,
@@ -50,6 +56,7 @@ import {
deleteAppIdentitySettings,
type AppearanceUpdate,
type AppIdentityUpdate,
type CallMetricsSettings,
type CitationDisplayUpdate,
type ConversationContextUpdate,
type RoundtableRoomLimitUpdate,
@@ -58,6 +65,7 @@ import {
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type OrdinaryKnowledgeRetrievalUpdate,
type PageDescriptionsUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
@@ -65,6 +73,7 @@ import {
type PublishSquaresUpdate,
type RetryKeepPartialSettings,
type SkillCompressionUpdate,
type SkillVersioningSettings,
type StrategyProjectSettingsUpdate,
type LayoutMode,
type ViewMode,
@@ -81,12 +90,14 @@ const HTML_MAP_KEY = ["systemSettings", "htmlMap"] as const;
const HTML_RUNTIME_KEY = ["systemSettings", "htmlRuntime"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const SKILL_VERSIONING_KEY = ["systemSettings", "skillVersioning"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const WRITE_FILE_COMPATIBILITY_KEY = [
"systemSettings",
"writeFileCompatibility",
] as const;
const CALL_METRICS_KEY = ["systemSettings", "callMetrics"] as const;
const CONVERSATION_CONTEXT_KEY = ["systemSettings", "conversationContext"] as const;
const ROUNDTABLE_ROOM_LIMIT_KEY = ["systemSettings", "roundtableRoomLimit"] as const;
const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
@@ -95,6 +106,7 @@ const ORDINARY_KNOWLEDGE_RETRIEVAL_KEY = [
] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
const PAGE_DESCRIPTIONS_KEY = ["systemSettings", "pageDescriptions"] as const;
export const ASSISTANT_KNOWLEDGE_VISIBILITY_KEY = [
"system-settings",
"knowledge-base-product-version",
@@ -145,6 +157,27 @@ export function useUpdateStrategyProjectSettings() {
});
}
export function usePageDescriptionsSettings() {
return useQuery({
queryKey: PAGE_DESCRIPTIONS_KEY,
queryFn: loadPageDescriptionsSettings,
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdatePageDescriptionsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PageDescriptionsUpdate) =>
updatePageDescriptionsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PAGE_DESCRIPTIONS_KEY, data);
},
});
}
export function usePromptPrefixSettings() {
return useQuery({
queryKey: PROMPT_PREFIX_KEY,
@@ -225,6 +258,28 @@ export function useUpdateWriteFileCompatibilitySettings() {
});
}
export function useCallMetricsSettings() {
return useQuery({
queryKey: CALL_METRICS_KEY,
queryFn: loadCallMetricsSettings,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateCallMetricsSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CallMetricsSettings) => updateCallMetricsSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(CALL_METRICS_KEY, data);
void queryClient.invalidateQueries({
queryKey: ["admin-activity", "leaderboard"],
});
},
});
}
export function useConversationContextSettings() {
return useQuery({
queryKey: CONVERSATION_CONTEXT_KEY,
@@ -554,6 +609,29 @@ export function useUpdateSkillCompressionSettings() {
});
}
/** Deployment-wide custom-skill versioning feature gate. */
export function useSkillVersioningSettings() {
return useQuery({
queryKey: SKILL_VERSIONING_KEY,
queryFn: () => loadSkillVersioningSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
export function useUpdateSkillVersioningSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: SkillVersioningSettings) =>
updateSkillVersioningSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(SKILL_VERSIONING_KEY, data);
void queryClient.invalidateQueries({ queryKey: ["skill-versions"] });
},
});
}
export function useModelConcurrencySettings() {
return useQuery({
queryKey: MODEL_CONCURRENCY_KEY,
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
// Node's type-stripping test runner requires the explicit TypeScript suffix.
// @ts-expect-error The application compiler intentionally uses extensionless imports.
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation.ts";
test("finds a public skill executed directly by a weak model", () => {
assert.equal(
extractInvokedSkillName("bash", {
command: "python /mnt/skills/public/facebook-page-generator/scripts/fast_page.py --spec /tmp/spec.json",
}),
"facebook-page-generator",
);
});
test("finds custom skills in host paths and nested arguments", () => {
assert.equal(
extractInvokedSkillName("read_file", {
request: { path: String.raw`F:\runtime\skills\custom\analysis-helper\SKILL.md` },
}),
"analysis-helper",
);
});
test("uses the explicit name for skill_view", () => {
assert.equal(extractInvokedSkillName("skill_view", { name: "report-maker" }), "report-maker");
});
test("does not label ordinary tool paths as skill calls", () => {
assert.equal(
extractInvokedSkillName("bash", { command: "python /mnt/user-data/workspace/run.py" }),
undefined,
);
});
test("renders a deterministic skill label without model-authored description", () => {
assert.equal(skillInvocationLabel("report-maker"), "调用技能「report-maker」");
});
@@ -0,0 +1,54 @@
const SKILL_NAME_ARG_TOOLS = new Set([
"skill_view",
"skill_manage",
"skill_archive",
]);
const SKILL_PATH_PATTERN =
/(?:^|[\\/])skills[\\/](?:public|custom)[\\/]([^\\/\s"'`;&|<>()]+)(?=[\\/]|$)/i;
function findSkillNameInValue(
value: unknown,
seen: Set<object>,
): string | undefined {
if (typeof value === "string") {
return SKILL_PATH_PATTERN.exec(value)?.[1];
}
if (!value || typeof value !== "object" || seen.has(value)) {
return undefined;
}
seen.add(value);
const values = Array.isArray(value)
? value
: Object.values(value as Record<string, unknown>);
for (const nested of values) {
const name = findSkillNameInValue(nested, seen);
if (name) {
return name;
}
}
return undefined;
}
/** Resolve a skill from actual tool arguments rather than model-authored prose. */
export function extractInvokedSkillName(
toolName: string,
args: unknown,
): string | undefined {
if (
SKILL_NAME_ARG_TOOLS.has(toolName) &&
args &&
typeof args === "object" &&
typeof (args as Record<string, unknown>).name === "string"
) {
const name = ((args as Record<string, unknown>).name as string).trim();
if (name) {
return name;
}
}
return findSkillNameInValue(args, new Set());
}
export function skillInvocationLabel(skillName: string): string {
return `调用技能「${skillName}」`;
}
+7
View File
@@ -3,6 +3,10 @@ import type { AIMessage } from "@langchain/langgraph-sdk";
import type { Translations } from "../i18n";
import { hasToolCalls } from "../messages/utils";
import {
extractInvokedSkillName,
skillInvocationLabel,
} from "./skill-invocation";
import { isInternalToolCallName } from "./visibility";
export function explainLastToolCall(message: AIMessage, t: Translations) {
@@ -18,6 +22,7 @@ export function explainLastToolCall(message: AIMessage, t: Translations) {
}
export function explainToolCall(toolCall: ToolCall, t: Translations) {
const invokedSkillName = extractInvokedSkillName(toolCall.name, toolCall.args);
if (toolCall.name === "web_search" || toolCall.name === "image_search") {
return t.toolCalls.searchFor(toolCall.args.query);
} else if (toolCall.name === "web_fetch") {
@@ -26,6 +31,8 @@ export function explainToolCall(toolCall: ToolCall, t: Translations) {
return t.toolCalls.presentFiles;
} else if (toolCall.name === "write_todos") {
return t.toolCalls.writeTodos;
} else if (invokedSkillName) {
return skillInvocationLabel(invokedSkillName);
} else if (toolCall.args.description) {
return toolCall.args.description;
} else {
+13
View File
@@ -49,6 +49,8 @@ export const env = {
* flag so operations can switch the shell without rebuilding the frontend.
*/
FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"),
/** Red-white theme opt-in for the same navigation shell used by dark mode. */
LIGHT_RED_TOP_NAV_ENABLED: readRuntimeFlag("VITE_LIGHT_RED_TOP_NAV_ENABLED"),
/**
* Development-only default entry switch for the isolated Vite-native
* official-style workspace. It never starts or embeds a Next.js app.
@@ -56,6 +58,13 @@ export const env = {
USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag(
"VITE_USE_OFFICIAL_DEERFLOW_ENTRY",
),
/**
* Knowledge-base card/list switch. Off by default so the compact list is
* always used unless a deployment explicitly opts into the alternate view.
*/
KNOWLEDGE_VIEW_SWITCH_ENABLED: readRuntimeFlag(
"VITE_KNOWLEDGE_VIEW_SWITCH_ENABLED",
),
/**
* Compact-menu variant for this website. Pair with the admin 简介模式
* type (A / B) so two deployments can show different left-nav items.
@@ -63,3 +72,7 @@ export const env = {
COMPACT_MENU_TYPE: readRuntimeEnv("VITE_COMPACT_MENU_TYPE"),
GITHUB_OAUTH_TOKEN: readRuntimeEnv("VITE_GITHUB_OAUTH_TOKEN"),
};
export function isLightRedTopNavEnabled(theme: string): boolean {
return theme === "light" && env.LIGHT_RED_TOP_NAV_ENABLED;
}
@@ -1,4 +1,5 @@
import { PenTool, Sliders, Sparkles } from 'lucide-react';
import { usePageDescriptionsSettings } from '@/core/system-settings/hooks';
/**
* AI 写作入口页左栏「欢迎/空状态」屏。
@@ -7,7 +8,7 @@ import { PenTool, Sliders, Sparkles } from 'lucide-react';
* 第一条消息,就回到正常的对话时间线。
*/
const STEPS = [
const DEFAULT_STEPS = [
{
n: '1',
title: '对白意图解析',
@@ -32,6 +33,19 @@ const STEPS = [
];
export function WritingWelcome() {
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configured = pageDescriptions?.ai_writing;
const title = configured?.title || '欢迎使用「ai写作」';
const description = configured?.description || '';
const steps = DEFAULT_STEPS.map((step, index) => {
const configuredCard = configured?.guide_cards[index];
return {
...step,
title: configuredCard?.title || step.title,
desc: configuredCard?.description || step.desc,
};
});
return (
<div className="size-full overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-6 py-7">
@@ -39,19 +53,25 @@ export function WritingWelcome() {
<div className="flex flex-1 flex-col justify-center gap-6">
<div>
<h2 className="text-2xl font-bold leading-snug tracking-tight text-slate-800 dark:text-white">
欢迎使用「ai写作」
{title}
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
{description ? (
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
{description}
</p>
) : (
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-500 dark:text-white/60">
这是一个专业的自动化写作系统。你只需在下方对话框中
<strong className="font-semibold text-slate-700 dark:text-white/80">
输入你的本意(例如:「帮我写一篇 1500 字的周报」)
</strong>
,系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{STEPS.map((step) => (
{steps.map((step) => (
<div
key={step.n}
className="flex flex-col gap-2 rounded-xl border border-slate-100/80 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50 dark:border-white/5 dark:bg-white/[0.02] dark:hover:bg-white/5"
@@ -27,6 +27,7 @@ import {
} from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { DateRangePicker, type DateRangePickerProps, type DateRangeValue } from "tdesign-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -42,11 +43,20 @@ import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor";
import type {
AdminLeaderboardDimension,
AdminLeaderboardDetailItem,
AdminLeaderboardDetailKind,
AdminLeaderboardOverview,
AdminLeaderboardResponse,
AdminLeaderboardSkill,
@@ -54,7 +64,7 @@ import type {
AdminLeaderboardUser,
} from "@/core/admin/api";
import { streamUserLeaderboardReport } from "@/core/admin/api";
import { useRefreshTodayLeaderboard, useUserLeaderboard } from "@/core/admin/hooks";
import { useRefreshTodayLeaderboard, useUserLeaderboard, useUserLeaderboardDetails } from "@/core/admin/hooks";
import {
ADMIN_QUERY_WINDOW_DAYS,
formatLocalDateInput,
@@ -70,6 +80,7 @@ type RangeState = { since: string; until: string; days?: number };
type LeaderboardAccent = "crimson" | "rose" | "amber" | "teal";
const DEFAULT_REPORT_MODEL = "__default__";
const LEADERBOARD_PAGE_SIZE = 10;
const DETAIL_PAGE_SIZE = 15;
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
crimson: {
@@ -776,11 +787,13 @@ function SkillRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.call_count));
return (
@@ -790,7 +803,14 @@ function SkillRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -834,11 +854,13 @@ function DimensionRanking({
icon: Icon,
items,
empty,
onMore,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardDimension[];
empty: string;
onMore?: () => void;
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
@@ -848,7 +870,14 @@ function DimensionRanking({
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
<div className="flex items-center gap-1">
<Badge variant="outline">{items.length} 项</Badge>
{onMore && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onMore}>
更多
</Button>
)}
</div>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">{empty}</div>}
@@ -886,6 +915,264 @@ function DimensionRanking({
);
}
function formatDuration(value: number): string {
if (value <= 0) return "-";
if (value < 1_000) return `${fmtNumber(value)} ms`;
return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)} 秒`;
}
function LeaderboardDetailRankingBars({
kind,
items,
}: {
kind: AdminLeaderboardDetailKind;
items: AdminLeaderboardDetailItem[];
}) {
const topItems = items.slice(0, 10);
if (topItems.length === 0) {
return <div className="flex h-40 items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">当前时间段暂无{kind === "agent" ? "智能体运行" : "调用"}数据</div>;
}
const max = Math.max(1, ...topItems.map((item) => item.count));
const verb = kind === "agent" ? "运行" : "调用";
return (
<div className="space-y-3" role="img" aria-label={`${kind === "agent" ? "智能体" : kind === "skill" ? "技能" : "工具"}${verb}次数前 ${topItems.length} 名柱状图`}>
{topItems.map((item, index) => {
const width = Math.max(4, (item.count / max) * 100);
return (
<div key={item.name} className="grid grid-cols-[24px_minmax(120px,1fr)_minmax(180px,2fr)_64px] items-center gap-3">
<span className="text-sm font-medium tabular-nums text-[#80868B]">{index + 1}</span>
<span className="truncate text-sm font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</span>
<div className="h-3 overflow-hidden rounded-full bg-[#F1D6D6] dark:bg-[#0D2D54]">
<div
className="h-full rounded-full bg-[#C1121F] dark:bg-[#2563EB]"
style={{ width: `${width}%` }}
/>
</div>
<span className="text-right text-sm font-semibold tabular-nums text-[#202124] dark:text-white">{fmtNumber(item.count)} 次</span>
</div>
);
})}
</div>
);
}
function LeaderboardDetailDrawer({
kind,
range,
onRangeChange,
onOpenChange,
}: {
kind: AdminLeaderboardDetailKind | null;
range: RangeState;
onRangeChange: (range: RangeState) => void;
onOpenChange: (open: boolean) => void;
}) {
const dateBounds = getAdminQueryDateBounds();
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"count" | "users">("count");
const [page, setPage] = useState(1);
const apiRange = useMemo(() => rangeForApi(range), [range]);
const { data, isLoading, error } = useUserLeaderboardDetails(kind, apiRange, !!kind);
const rangePickerValue = useMemo<DateRangeValue>(() => [range.since, range.until], [range.since, range.until]);
const rangePickerPresets = useMemo<DateRangePickerProps["presets"]>(() => {
const lastSevenDays = defaultRange(7);
const lastThirtyDays = defaultRange(30);
return {
"近 7 天": [lastSevenDays.since, lastSevenDays.until] as [string, string],
"近 30 天": [lastThirtyDays.since, lastThirtyDays.until] as [string, string],
};
}, []);
const detailMeta = kind === "skill"
? { title: "技能调用统计", plural: "技能", verb: "调用", unit: "次调用", icon: Sparkles }
: kind === "tool"
? { title: "工具调用统计", plural: "工具", verb: "调用", unit: "次调用", icon: Activity }
: { title: "Agent 使用统计", plural: "智能体", verb: "运行", unit: "次运行", icon: Bot };
const DetailIcon = detailMeta.icon;
const filteredItems = useMemo(() => {
const needle = search.trim().toLowerCase();
const items = (data?.items ?? []).filter((item) => !needle || item.name.toLowerCase().includes(needle));
return [...items].sort((left, right) => {
if (sort === "users") return right.user_count - left.user_count || right.count - left.count;
return right.count - left.count || right.user_count - left.user_count;
});
}, [data?.items, search, sort]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / DETAIL_PAGE_SIZE));
const visibleItems = filteredItems.slice((page - 1) * DETAIL_PAGE_SIZE, page * DETAIL_PAGE_SIZE);
const summary = data?.summary;
useEffect(() => {
setPage(1);
}, [kind, apiRange.since, apiRange.until, search, sort]);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);
useEffect(() => {
if (!kind) {
setSearch("");
setSort("count");
setPage(1);
}
}, [kind]);
const setPickerRange = (value: DateRangeValue) => {
if (value.length < 2) return;
const normalizeDate = (date: DateRangeValue[number]) => {
if (date instanceof Date) return formatLocalDateInput(date);
if (typeof date === "number") return formatLocalDateInput(new Date(date));
return String(date).slice(0, 10);
};
const since = normalizeDate(value[0]);
const until = normalizeDate(value[1]);
if (!dateFromInput(since) || !dateFromInput(until)) return;
onRangeChange(clampRangeToAdminWindow({ since, until }));
};
const metric = (label: string, value: string, description: string) => (
<div className="rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-3 dark:border-[#213D5E] dark:bg-[#011226]" title={description}>
<div className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums text-[#231815] dark:text-white">{value}</div>
<div className="mt-1 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{description}</div>
</div>
);
return (
<Sheet open={!!kind} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:w-[min(94vw,1080px)] sm:max-w-none">
<SheetHeader className="shrink-0 border-b border-[#F1D6D6] bg-white px-5 pb-4 pt-4 pr-12 text-left dark:border-[#213D5E] dark:bg-[#02244C]">
<SheetTitle className="flex items-center gap-2 text-base">
<DetailIcon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
{detailMeta.title}
</SheetTitle>
<SheetDescription className="mt-1 leading-5">
直接聚合原始统计记录;图表和明细会随当前时间范围重新汇总,不受主榜单快照刷新影响。
</SheetDescription>
</SheetHeader>
<div className="shrink-0 space-y-3 border-b border-[#F1D6D6] bg-[#FFF7F7] px-5 py-3 dark:border-[#213D5E] dark:bg-[#011226]">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-medium text-[#202124] dark:text-white">统计区间</span>
<DateRangePicker
value={rangePickerValue}
valueType="YYYY-MM-DD"
format="YYYY-MM-DD"
presets={rangePickerPresets}
clearable={false}
disableDate={{ before: dateBounds.min, after: dateBounds.max }}
onChange={setPickerRange}
className="w-full sm:w-80"
/>
<span className="text-xs text-[#7A5A5A] dark:text-[#7FACF2]">仅支持近 30 天</span>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-5">
{isLoading && (
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="size-4 animate-spin" />
正在加载{detailMeta.plural}明细
</div>
)}
{error && (
<div role="alert" className="rounded-lg border border-[#FAD2CF] bg-[#FCE8E6] p-4 text-sm text-[#DB4437] dark:border-[#DB4437]/30 dark:bg-[#DB4437]/10 dark:text-[#F28B82]">
明细加载失败:{error instanceof Error ? error.message : "请稍后重试"}
</div>
)}
{!isLoading && !error && summary && (
<>
<section>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">整体概览</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">{range.since} 至 {range.until} · {fmtNumber(summary.item_count)} 个{detailMeta.plural}</p>
</div>
<Badge variant="outline">{fmtNumber(summary.count)} {detailMeta.unit}</Badge>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metric(`总${detailMeta.verb}量`, `${fmtNumber(summary.count)} 次`, `所选时间内的${detailMeta.verb}总数`) }
{metric("覆盖用户", `${fmtNumber(summary.user_count)} 人`, "至少使用过一次该维度的去重用户数")}
{metric(kind === "agent" ? "总 Token" : "平均耗时", kind === "agent" ? fmtNumber(summary.total_tokens) : formatDuration(summary.avg_duration_ms), kind === "agent" ? "此类 Agent 运行累计的模型 Token" : "按每次完成调用计算的平均耗时")}
{metric("活跃条目", `${fmtNumber(summary.item_count)} 项`, "当前条件下产生过统计记录的名称数量")}
</div>
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}{detailMeta.verb} Top 10</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">按当前筛选的完整时间范围累计排序,数值越大柱越长。</p>
</div>
<Badge variant="outline">{range.since} 至 {range.until}</Badge>
</div>
<LeaderboardDetailRankingBars kind={kind!} items={data?.items ?? []} />
</section>
<section className="rounded-xl border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="font-semibold text-[#202124] dark:text-white">{detailMeta.plural}明细</h3>
<p className="mt-1 text-xs text-[#7A5A5A] dark:text-[#7FACF2]">共 {fmtNumber(filteredItems.length)} 项;可按名称筛选、按调用量或用户数排序。</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
<Input aria-label={`搜索${detailMeta.plural}名称`} value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`搜索${detailMeta.plural}名称`} className="h-9 w-52 bg-white pl-8 dark:bg-[#011226]" />
</div>
<Select value={sort} onValueChange={(value) => setSort(value as "count" | "users")}>
<SelectTrigger className="h-9 w-32 bg-white text-xs dark:bg-[#011226]" aria-label="明细排序方式">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count">按调用量</SelectItem>
<SelectItem value="users">按用户数</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{visibleItems.length === 0 ? (
<div className="py-16 text-center text-sm text-[#5F6368] dark:text-slate-400">当前筛选条件下暂无{detailMeta.plural}数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="border-y border-[#F1D6D6] bg-[#FFF7F7] text-xs text-[#7A5A5A] dark:border-[#213D5E] dark:bg-[#011226] dark:text-[#7FACF2]">
<tr>
<th className="px-3 py-2.5 font-medium">名称</th>
<th className="px-3 py-2.5 text-right font-medium">{detailMeta.verb}量</th>
<th className="px-3 py-2.5 text-right font-medium">用户</th>
<th className="px-3 py-2.5 text-right font-medium">{kind === "agent" ? "Token" : "平均耗时"}</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item: AdminLeaderboardDetailItem) => (
<tr key={item.name} className="border-b border-[#F6E6E6] last:border-0 dark:border-[#17375E]">
<td className="max-w-64 truncate px-3 py-3 font-medium text-[#202124] dark:text-white" title={item.name}>{item.name}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{fmtNumber(item.user_count)}</td>
<td className="px-3 py-3 text-right tabular-nums">{kind === "agent" ? fmtNumber(item.total_tokens) : formatDuration(item.avg_duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs text-[#6B7280] dark:text-slate-400">
<span>第 {page} / {pageCount} 页</span>
<Pagination page={page} pageSize={DETAIL_PAGE_SIZE} total={filteredItems.length} onPageChange={setPage} />
</div>
</section>
</>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-r border-[#F1D6D6] px-3 py-2 last:border-r-0 dark:border-[#213D5E]">
@@ -904,6 +1191,8 @@ export default function AdminUserLeaderboardPage() {
const [groupRules, setGroupRules] = useState("zero,two");
const [showGroupStats, setShowGroupStats] = useState(false);
const [userPage, setUserPage] = useState(1);
const [detailKind, setDetailKind] = useState<AdminLeaderboardDetailKind | null>(null);
const [detailRange, setDetailRange] = useState<RangeState>(() => defaultRange(7));
const apiRange = useMemo(
() => rangeForApi(range),
[range],
@@ -968,6 +1257,11 @@ export default function AdminUserLeaderboardPage() {
setRange(clampRangeToAdminWindow(defaultRange(days)));
};
const openLeaderboardDetails = (kind: AdminLeaderboardDetailKind) => {
setDetailRange(clampRangeToAdminWindow(range));
setDetailKind(kind);
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) {
@@ -1438,19 +1732,27 @@ export default function AdminUserLeaderboardPage() {
</section>
<div className="grid gap-5 xl:grid-cols-2">
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
<SkillRanking title="最常用技能" icon={Sparkles} items={data.skills} empty="当前时间段暂无技能调用" onMore={() => openLeaderboardDetails("skill")} />
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" onMore={() => openLeaderboardDetails("tool")} />
</div>
<div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" onMore={() => openLeaderboardDetails("agent")} />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
</>
)}
</div>
</ScrollArea>
<LeaderboardDetailDrawer
kind={detailKind}
range={detailRange}
onRangeChange={setDetailRange}
onOpenChange={(open) => {
if (!open) setDetailKind(null);
}}
/>
</main>
</div>
);
+14 -5
View File
@@ -51,7 +51,10 @@ import {
type ArtifactLibrarySource,
} from "@/core/artifacts/library";
import { useI18n } from "@/core/i18n/hooks";
import { useAppSystemName } from "@/core/system-settings/hooks";
import {
useAppSystemName,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { bootstrapResumePath, pathOfThread } from "@/core/threads/utils";
import { formatDateTime } from "@/core/utils/datetime";
import {
@@ -328,6 +331,12 @@ function LoadingRows() {
export default function ArtifactLibraryPage() {
const { t } = useI18n();
const appName = useAppSystemName(t.pages.appName);
const { data: pageDescriptions } = usePageDescriptionsSettings();
const configuredDescription = pageDescriptions?.knowledge_base;
const pageTitle = configuredDescription?.title || "资料库";
const pageDescription =
configuredDescription?.description ||
"汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。";
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileKind, setFileKind] = useState<ArtifactLibraryKind>("all");
@@ -337,8 +346,8 @@ export default function ArtifactLibraryPage() {
const forceRefreshRef = useRef(false);
useEffect(() => {
document.title = `资料库 - ${appName}`;
}, [appName]);
document.title = `${pageTitle} - ${appName}`;
}, [appName, pageTitle]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -478,10 +487,10 @@ export default function ArtifactLibraryPage() {
<div>
<div className="flex items-center gap-2">
<Library className="size-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">资料库</h1>
<h1 className="text-2xl font-semibold tracking-tight">{pageTitle}</h1>
</div>
<p className="mt-2 text-sm text-muted-foreground">
汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。
{pageDescription}
</p>
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end">
+23 -1
View File
@@ -11,6 +11,7 @@ import {
useConversationContextSettings,
useLayoutMode,
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
usePromptPrefixSettings,
} from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
@@ -168,6 +169,7 @@ export default function ChatPage() {
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
useSidebar();
const { t } = useI18n();
const { data: pageDescriptions } = usePageDescriptionsSettings();
// 有效界面模式:用户自选 > 管理员系统默认 > 内置默认「简洁模式」
const [effectiveLayoutMode] = useLayoutMode();
const { data: appearance } = useAppearanceSettings();
@@ -1003,6 +1005,17 @@ export default function ChatPage() {
const compactChrome =
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
const compactLanding = isLandingState && compactChrome;
// Configuration follows the user-facing interface mode, rather than the
// internal welcome component name. The compact-looking welcome component
// is also used by the full-chrome surface, so tying settings to that name
// would invert the administrator's "全量 / 简洁" configuration.
const qaDescriptionMode =
fullChatMode ||
pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX) ||
effectiveLayoutMode === "full"
? "full"
: "compact";
const qaPageDescription = pageDescriptions?.general_qa[qaDescriptionMode];
const composerWidthMode = resolveComposerWidthMode({
fullChatMode,
wxFullChat: pathname.startsWith(WX_FULL_CHAT_PATH_PREFIX),
@@ -1231,11 +1244,15 @@ export default function ChatPage() {
</div>
{isLandingState && mounted &&
(compactLanding ? (
<CompactWelcome className="chat-landing-content-lift mb-6" />
<CompactWelcome
className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/>
) : (
<Welcome
mode={settings.context.mode}
className="chat-landing-content-lift mb-6"
pageDescription={qaPageDescription}
/>
))}
{mounted ? (
@@ -1263,6 +1280,11 @@ export default function ChatPage() {
<InputBox
className="w-full"
isNewThread={isLandingState}
placeholder={
isLandingState
? qaPageDescription?.input_placeholder
: undefined
}
compact={compactLanding}
composerHeightPercent={composerHeightPercent}
threadId={threadId}
@@ -40,6 +40,7 @@ import {
useNoteWikilinks,
useVaultPage,
} from "@/core/knowledge/hooks";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import type { KnowledgeNote } from "@/core/knowledge/types";
import { cn } from "@/lib/utils";
import MarkdownRenderer, {
@@ -537,6 +538,8 @@ export default function KnowledgeBasePage({
} = {}) {
const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
// --- list / tree state ---
const [keyword, setKeyword] = useState("");
@@ -649,6 +652,8 @@ export default function KnowledgeBasePage({
return (
<div className="flex size-full min-h-0 flex-col bg-slate-50 dark:bg-slate-950">
<WikiHeader
title={pageDescription?.title || undefined}
description={pageDescription?.description || undefined}
onToggleSidebar={() => setMobileSidebarOpen((v) => !v)}
onImportFile={() => setImportOpen(true)}
onBatchImport={() => setBatchOpen(true)}
+5 -2
View File
@@ -36,6 +36,7 @@ import { Pagination } from "@/components/ui/pagination";
import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import { isCustomThemeId } from "@/core/theme/custom-theme";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
@@ -140,6 +141,8 @@ export default function LightAppListPage() {
const [copiedId, setCopiedId] = useState<string | null>(null);
const { theme } = useTheme();
const lightAccent = getLightAccent(theme);
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_list;
const { data: apps = [], isLoading } = useLightApps({
appType: "window_open",
@@ -199,14 +202,14 @@ export default function LightAppListPage() {
<span className={cn("relative inline-flex size-3 rounded-full", lightAccent.dot, "dark:bg-[#1677FF]")} />
</span>
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
应用列表
{pageDescription?.title || "应用列表"}
</h1>
<span className={cn("rounded-[5px] border px-2 py-0.5 font-mono text-xs font-medium dark:border-[#1677FF]/30 dark:bg-[#1677FF]/15 dark:text-[#4096FF]", lightAccent.badge)}>
导航工作台
</span>
</div>
<p className="max-w-2xl text-sm leading-relaxed text-zinc-500 dark:text-[#6E88B0]">
点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。
{pageDescription?.description || "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。"}
</p>
</div>
@@ -36,6 +36,7 @@ import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { useSetResourceTags } from "@/core/tags";
import {
useLightAppActions,
@@ -142,6 +143,8 @@ export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.light_app_management;
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
@@ -327,18 +330,20 @@ export default function LightAppManagementPage() {
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
{pageDescription?.title || "应用管理"}
</h1>
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
{pageDescription?.description || <>
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
</code>
类型在「应用列表」以卡片展示;
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
iframe
</code>
类型挂载到所选菜单下并注入侧栏。
</>}
</p>
</div>
@@ -0,0 +1,497 @@
"use client";
import {
BotIcon,
DatabaseIcon,
FileTextIcon,
FolderKanbanIcon,
GitBranchIcon,
LandmarkIcon,
Layout,
List,
Loader2Icon,
MonitorIcon,
PenLineIcon,
RotateCcwIcon,
SaveIcon,
SparklesIcon,
TelescopeIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type {
AIWritingGuideCard,
AIWritingPageDescription,
GeneralQaPageDescription,
PageDescriptionsSettings,
} from "@/core/system-settings/api";
import {
usePageDescriptionsSettings,
useUpdatePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
type PageTarget =
| "qa-compact"
| "qa-full"
| "knowledge-base"
| "ai-writing"
| "strategy-projects"
| "deep-research"
| "gongwen"
| "light-app-management"
| "light-app-list"
| "agents"
| "business-chains"
| "skills"
| "knowledge-management";
type HeaderTarget = Exclude<PageTarget, "qa-compact" | "qa-full">;
type HeaderKey =
| "knowledge_base"
| "ai_writing"
| "strategy_projects"
| "deep_research"
| "gongwen"
| "light_app_management"
| "light_app_list"
| "agents"
| "business_chains"
| "skills"
| "knowledge_management";
const EMPTY_QA: GeneralQaPageDescription = {
welcome_title: "",
welcome_description: "",
input_placeholder: "",
};
const EMPTY_HEADER = { title: "", description: "" };
const DEFAULT_AI_WRITING_GUIDE_CARDS: AIWritingGuideCard[] = [
{
title: "对白意图解析",
description: "在底栏用人话随便说出你的想法,AI 会瞬间解析要求并填充右侧配置。",
},
{
title: "参数多端对齐",
description: "右侧配置框即刻对齐所需的体裁、目标行文大纲和字数限制。",
},
{
title: "深度排版落成",
description: "点击右侧红色创作按钮,精美的行业或创意文档极速铺开。",
},
];
const EMPTY_AI_WRITING: AIWritingPageDescription = {
...EMPTY_HEADER,
guide_cards: DEFAULT_AI_WRITING_GUIDE_CARDS.map(() => ({
title: "",
description: "",
})),
};
const EMPTY_SETTINGS: PageDescriptionsSettings = {
general_qa: { full: EMPTY_QA, compact: EMPTY_QA },
knowledge_base: EMPTY_HEADER,
ai_writing: EMPTY_AI_WRITING,
strategy_projects: EMPTY_HEADER,
deep_research: EMPTY_HEADER,
gongwen: EMPTY_HEADER,
light_app_management: EMPTY_HEADER,
light_app_list: EMPTY_HEADER,
agents: EMPTY_HEADER,
business_chains: EMPTY_HEADER,
skills: EMPTY_HEADER,
knowledge_management: EMPTY_HEADER,
};
const HEADER_KEY_BY_TARGET: Record<HeaderTarget, HeaderKey> = {
"knowledge-base": "knowledge_base",
"ai-writing": "ai_writing",
"strategy-projects": "strategy_projects",
"deep-research": "deep_research",
gongwen: "gongwen",
"light-app-management": "light_app_management",
"light-app-list": "light_app_list",
agents: "agents",
"business-chains": "business_chains",
skills: "skills",
"knowledge-management": "knowledge_management",
};
const PAGES: Array<{
id: PageTarget;
title: string;
subtitle: string;
path: string;
defaultTitle: string;
defaultDescription: string;
}> = [
{
id: "qa-compact",
title: "通用问答 · 简洁模式",
subtitle: "紧凑工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "qa-full",
title: "通用问答 · 全量模式",
subtitle: "完整工作台的新对话欢迎区",
path: "/page/workspace/chats/new",
defaultTitle: "欢迎使用CM助手",
defaultDescription: "基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。",
},
{
id: "knowledge-base",
title: "资料库",
subtitle: "资料库首页的标题与说明",
path: "/page/workspace/library",
defaultTitle: "资料库",
defaultDescription: "汇总当前账号在对话和策略优选中生成的文件,不包含上传文件、图片、音频和视频。",
},
{
id: "ai-writing",
title: "AI 写作",
subtitle: "AI 写作工作台的欢迎区",
path: "/page/canvas/ai-writing",
defaultTitle: "欢迎使用「ai写作」",
defaultDescription: "这是一个专业的自动化写作系统。你只需在下方对话框中输入你的本意(例如:「帮我写一篇 1500 字的周报」),系统即会自动为你在右侧填充所需的字数、体裁等格式进行对齐;你也可在右侧进行纯人工的参数深度定制,并一键开启 AI 深度协作创作。",
},
{
id: "strategy-projects",
title: "我的项目",
subtitle: "策略项目列表的标题与说明",
path: "/page/strategy/qa/projects",
defaultTitle: "我的项目",
defaultDescription: "集中查看普通问答、策略优选和方案成果",
},
{
id: "deep-research",
title: "深度研究",
subtitle: "深度研究工作台的欢迎区",
path: "/page/strategy/qa/deep-research",
defaultTitle: "开始一项深度研究",
defaultDescription: "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。",
},
{
id: "gongwen",
title: "公文助手",
subtitle: "公文助手工作台的欢迎区",
path: "/page/strategy/qa/gongwen",
defaultTitle: "开始一份公文工作",
defaultDescription: "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。",
},
{
id: "light-app-management",
title: "应用管理",
subtitle: "轻应用登记页的标题与说明",
path: "/page/strategy/light-app/management",
defaultTitle: "应用管理",
defaultDescription: "注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。",
},
{
id: "light-app-list",
title: "应用列表",
subtitle: "轻应用启动器的标题与说明",
path: "/page/strategy/light-app/apps",
defaultTitle: "应用列表",
defaultDescription: "点击卡片将在新标签页中一键直达应用。地址会自动、智能地拼接路由参数(跨域登录态、个性化主题等)。",
},
{
id: "agents",
title: "智能体管理",
subtitle: "智能体列表页的标题与说明",
path: "/page/strategy/agents",
defaultTitle: "智能体",
defaultDescription: "创建和管理具有专属 Prompt 与能力的自定义智能体。",
},
{
id: "business-chains",
title: "业务链管理",
subtitle: "业务链条配置页的标题与说明",
path: "/page/strategy/business-chains",
defaultTitle: "业务链条配置",
defaultDescription: "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。",
},
{
id: "skills",
title: "技能管理",
subtitle: "技能工作台的标题与说明",
path: "/page/strategy/skills",
defaultTitle: "技能",
defaultDescription: "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。",
},
{
id: "knowledge-management",
title: "知识库管理",
subtitle: "知识库工作台的标题与说明",
path: "/page/workspace/knowledge",
defaultTitle: "知识库管理",
defaultDescription: "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。",
},
];
const DEFAULT_INPUT_PLACEHOLDER = "今天我能为你做些什么?";
export default function PageDescriptionSettingsPage() {
const { data, isLoading, error } = usePageDescriptionsSettings();
const update = useUpdatePageDescriptionsSettings();
const [draft, setDraft] = useState<PageDescriptionsSettings>(EMPTY_SETTINGS);
const [activeTarget, setActiveTarget] = useState<PageTarget>("qa-compact");
useEffect(() => {
if (data) setDraft(data);
}, [data]);
const currentPage = PAGES.find((item) => item.id === activeTarget) ?? PAGES[0];
const isQa = activeTarget === "qa-compact" || activeTarget === "qa-full";
const qaMode = activeTarget === "qa-compact" ? "compact" : "full";
const qaDraft = draft.general_qa[qaMode];
const headerKey: HeaderKey = isQa
? "knowledge_base"
: HEADER_KEY_BY_TARGET[activeTarget as HeaderTarget];
const headerDraft = draft[headerKey];
const updateQa = (patch: Partial<GeneralQaPageDescription>) => {
setDraft((current) => ({
...current,
general_qa: {
...current.general_qa,
[qaMode]: { ...current.general_qa[qaMode], ...patch },
},
}));
};
const updateHeader = (patch: typeof EMPTY_HEADER) => {
setDraft((current) => ({ ...current, [headerKey]: { ...current[headerKey], ...patch } }));
};
const updateAiWritingCard = (
index: number,
patch: Partial<AIWritingGuideCard>,
) => {
setDraft((current) => ({
...current,
ai_writing: {
...current.ai_writing,
guide_cards: current.ai_writing.guide_cards.map((card, cardIndex) =>
cardIndex === index ? { ...card, ...patch } : card,
),
},
}));
};
const resetActive = () => {
if (isQa) updateQa(EMPTY_QA);
else if (activeTarget === "ai-writing") {
setDraft((current) => ({ ...current, ai_writing: EMPTY_AI_WRITING }));
} else updateHeader(EMPTY_HEADER);
};
const handleSave = async () => {
const normalizeQa = (value: GeneralQaPageDescription): GeneralQaPageDescription => ({
welcome_title: value.welcome_title.trim(),
welcome_description: value.welcome_description.trim(),
input_placeholder: value.input_placeholder.trim(),
});
const normalizeHeader = (value: typeof EMPTY_HEADER) => ({
title: value.title.trim(),
description: value.description.trim(),
});
const normalizeAiWriting = (value: AIWritingPageDescription) => ({
...normalizeHeader(value),
guide_cards: value.guide_cards.map((card) => ({
title: card.title.trim(),
description: card.description.trim(),
})),
});
try {
await update.mutateAsync({
general_qa: { full: normalizeQa(draft.general_qa.full), compact: normalizeQa(draft.general_qa.compact) },
knowledge_base: normalizeHeader(draft.knowledge_base),
ai_writing: normalizeAiWriting(draft.ai_writing),
strategy_projects: normalizeHeader(draft.strategy_projects),
deep_research: normalizeHeader(draft.deep_research),
gongwen: normalizeHeader(draft.gongwen),
light_app_management: normalizeHeader(draft.light_app_management),
light_app_list: normalizeHeader(draft.light_app_list),
agents: normalizeHeader(draft.agents),
business_chains: normalizeHeader(draft.business_chains),
skills: normalizeHeader(draft.skills),
knowledge_management: normalizeHeader(draft.knowledge_management),
});
toast.success("页面描述已保存,重新进入对应页面后即可查看新文案。");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存页面描述配置失败");
}
};
const title = isQa ? qaDraft.welcome_title : headerDraft.title;
const description = isQa ? qaDraft.welcome_description : headerDraft.description;
const previewTitle = title || currentPage.defaultTitle;
const previewDescription = description || currentPage.defaultDescription;
const previewPlaceholder = qaDraft.input_placeholder || DEFAULT_INPUT_PLACEHOLDER;
const aiWritingPreviewCards = draft.ai_writing.guide_cards.map((card, index) => ({
title: card.title || DEFAULT_AI_WRITING_GUIDE_CARDS[index].title,
description: card.description || DEFAULT_AI_WRITING_GUIDE_CARDS[index].description,
}));
return (
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6 sm:py-8 lg:overflow-hidden">
<header className="shrink-0">
<h1 className="text-xl font-semibold tracking-tight">页面描述配置</h1>
<p className="text-muted-foreground mt-1 max-w-3xl text-sm leading-relaxed">
按页面维护用户可见的标题和说明文字。通用问答的简洁模式与全量模式可分别配置;留空会沿用页面默认文案。
</p>
</header>
{isLoading ? (
<div className="flex min-h-72 items-center justify-center" aria-label="正在加载页面描述配置">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed p-8 text-center text-sm">加载页面描述配置失败:{error.message}</div>
) : (
<div className="grid gap-5 lg:min-h-0 lg:flex-1 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="rounded-xl border bg-card p-3 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<p className="text-muted-foreground sticky top-0 z-10 bg-card px-2 pb-2 text-xs font-medium tracking-wide">可配置页面</p>
<div className="grid gap-1">
{PAGES.map((item) => {
const active = activeTarget === item.id;
const Icon = item.id.startsWith("qa-")
? MonitorIcon
: item.id === "knowledge-base"
? DatabaseIcon
: item.id === "ai-writing"
? PenLineIcon
: item.id === "strategy-projects"
? FolderKanbanIcon
: item.id === "deep-research"
? TelescopeIcon
: item.id === "gongwen"
? LandmarkIcon
: item.id === "light-app-management"
? Layout
: item.id === "light-app-list"
? List
: item.id === "agents"
? BotIcon
: item.id === "business-chains"
? GitBranchIcon
: item.id === "skills"
? SparklesIcon
: DatabaseIcon;
return (
<button
key={item.id}
type="button"
onClick={() => setActiveTarget(item.id)}
aria-pressed={active}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active ? "bg-primary/10 text-foreground ring-1 ring-primary/20" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className={cn("mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md", active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground")}>
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0"><span className="block text-sm font-medium">{item.title}</span><span className="mt-0.5 block text-xs leading-relaxed">{item.subtitle}</span></span>
</button>
);
})}
</div>
</aside>
<main className="min-w-0 space-y-5 lg:min-h-0 lg:overflow-y-scroll lg:overscroll-contain lg:pr-2">
<section className="rounded-xl border bg-card p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2"><FileTextIcon className="text-primary size-4" aria-hidden /><h2 className="text-base font-semibold">{currentPage.title}</h2></div>
<p className="text-muted-foreground mt-1 text-sm">配置 <code className="font-mono text-xs">{currentPage.path}</code> 的页面文案。</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={resetActive} disabled={update.isPending}><RotateCcwIcon className="mr-1.5 size-4" aria-hidden />恢复默认文案</Button>
</div>
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5">
<span className="text-sm font-medium">{isQa ? "欢迎标题" : "页面标题"}</span>
<span className="text-muted-foreground text-xs">留空时使用系统默认文案。</span>
<Input value={title} maxLength={80} onChange={(event) => isQa ? updateQa({ welcome_title: event.target.value }) : updateHeader({ title: event.target.value, description: headerDraft.description })} placeholder={currentPage.defaultTitle} />
</label>
<label className="grid gap-1.5">
<span className="text-sm font-medium">说明文字</span>
<span className="text-muted-foreground text-xs">建议用一句话说明该页面能为用户做什么。</span>
<Textarea rows={4} value={description} maxLength={500} onChange={(event) => isQa ? updateQa({ welcome_description: event.target.value }) : updateHeader({ title: headerDraft.title, description: event.target.value })} placeholder={currentPage.defaultDescription} />
</label>
{isQa && <label className="grid gap-1.5"><span className="text-sm font-medium">输入框提示语</span><span className="text-muted-foreground text-xs">在用户开始输入前显示于问答输入框中。</span><Input value={qaDraft.input_placeholder} maxLength={120} onChange={(event) => updateQa({ input_placeholder: event.target.value })} placeholder={DEFAULT_INPUT_PLACEHOLDER} /></label>}
{activeTarget === "ai-writing" && (
<fieldset className="grid gap-3 rounded-lg border bg-muted/20 p-4">
<legend className="px-1 text-sm font-medium">引导卡片</legend>
<p className="text-muted-foreground text-xs leading-relaxed">
三张卡片按页面既有顺序展示;可分别修改标题和说明,留空则保留默认文案。
</p>
<div className="grid gap-3 xl:grid-cols-3">
{draft.ai_writing.guide_cards.map((card, index) => (
<section key={index} className="grid gap-3 rounded-md border bg-card p-3">
<h3 className="text-sm font-medium">卡片 {index + 1}</h3>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">标题</span>
<Input
value={card.title}
maxLength={80}
onChange={(event) =>
updateAiWritingCard(index, { title: event.target.value })
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].title}
/>
</label>
<label className="grid gap-1.5">
<span className="text-muted-foreground text-xs">说明</span>
<Textarea
rows={4}
value={card.description}
maxLength={240}
onChange={(event) =>
updateAiWritingCard(index, {
description: event.target.value,
})
}
placeholder={DEFAULT_AI_WRITING_GUIDE_CARDS[index].description}
/>
</label>
</section>
))}
</div>
</fieldset>
)}
</div>
</section>
<section className="rounded-xl border bg-muted/25 p-4 sm:p-5" aria-live="polite">
<div className="mb-4 flex items-center gap-2"><MonitorIcon className="text-muted-foreground size-4" aria-hidden /><h2 className="text-sm font-semibold">页面预览</h2><span className="text-muted-foreground text-xs">{currentPage.title}</span></div>
<div className="rounded-lg border bg-background px-5 py-8 text-center shadow-sm sm:px-10">
<h3 className="text-xl font-semibold tracking-tight">{previewTitle}</h3>
<p className="text-muted-foreground mx-auto mt-2 max-w-xl text-sm leading-relaxed">{previewDescription}</p>
{isQa && <div className="text-muted-foreground mt-6 rounded-xl border bg-card px-4 py-3 text-left text-sm shadow-xs">{previewPlaceholder}</div>}
{activeTarget === "ai-writing" && (
<div className="mt-6 grid gap-3 text-left md:grid-cols-3">
{aiWritingPreviewCards.map((card, index) => (
<div key={index} className="rounded-lg border bg-card p-3">
<p className="text-sm font-semibold">{index + 1}. {card.title}</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">{card.description}</p>
</div>
))}
</div>
)}
</div>
</section>
<div className="flex justify-end"><Button type="button" onClick={handleSave} disabled={update.isPending} className={cn("min-w-28", update.isPending && "cursor-wait")}>{update.isPending ? <Loader2Icon className="mr-1.5 size-4 animate-spin" aria-hidden /> : <SaveIcon className="mr-1.5 size-4" aria-hidden />}保存配置</Button></div>
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
import { HistoryIcon, Loader2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useSkillVersioningSettings,
useUpdateSkillVersioningSettings,
} from "@/core/system-settings/hooks";
export default function SkillVersioningSettingsPage({
embedded = false,
}: {
embedded?: boolean;
} = {}) {
const { data, isLoading, error } = useSkillVersioningSettings();
const update = useUpdateSkillVersioningSettings();
const [enabled, setEnabled] = useState(false);
useEffect(() => {
if (data) setEnabled(data.enabled);
}, [data]);
async function handleSave() {
try {
await update.mutateAsync({ enabled });
toast.success(enabled ? "技能迭代已开启" : "技能迭代已关闭");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存失败");
}
}
return (
<div
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"
}
>
<div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>
自定义技能迭代
</h1>
<p className="text-muted-foreground mt-1 text-sm leading-relaxed">
开启后,同名自定义技能上传时可选择“迭代为新版本”或“直接覆盖”,并可在技能详情中切换版本。
旧版本不会提供给大模型,技能调用次数也不会因迭代或切换而清零。关闭后保持原有覆盖逻辑。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-12 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 gap-3">
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
<HistoryIcon className="size-4" />
</div>
<div>
<div className="text-sm font-medium">启用自定义技能迭代</div>
<div className="text-muted-foreground mt-1 text-xs leading-relaxed">
仅作用于 custom 技能。第一次开启时会为现有自定义技能建立初始版本,公共/内置技能不参与迭代。
</div>
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="启用自定义技能迭代"
/>
</div>
<div className="bg-muted/40 text-muted-foreground rounded-lg border px-4 py-3 text-xs leading-relaxed">
“迭代为新版本”会保留完整旧技能包;“直接覆盖”会按旧逻辑替换内容,并从当前上传内容重新作为第 1 版开始记录。
</div>
<div className="flex justify-end">
<Button onClick={() => void handleSave()} disabled={update.isPending}>
{update.isPending && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
保存
</Button>
</div>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -5,6 +5,7 @@ import { useSearchParams } from "react-router-dom";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { SkillKnowledgePanel } from "@/components/workspace/skills/SkillKnowledgePanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
export default function SkillsPage({
restrictWxTypes = false,
@@ -13,6 +14,8 @@ export default function SkillsPage({
restrictWxTypes?: boolean;
} = {}) {
const [searchParams, setSearchParams] = useSearchParams();
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.skills;
const tab = useMemo(
() => (searchParams.get("tab") === "knowledge" ? "knowledge" : "skills"),
[searchParams],
@@ -33,9 +36,9 @@ export default function SkillsPage({
>
<header className="flex flex-wrap items-center justify-between gap-4 border-b bg-background px-6 py-4">
<div>
<h1 className="text-xl font-semibold">技能</h1>
<h1 className="text-xl font-semibold">{pageDescription?.title || "技能"}</h1>
<p className="mt-1 text-sm text-muted-foreground">
浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。
{pageDescription?.description || "浏览和维护技能,也可以把多个技能的知识一次归纳到一个 Wiki 知识库。"}
</p>
</div>
<TabsList>
@@ -60,6 +60,9 @@ const AdminUserLeaderboardSettingsPage = lazy(
);
const MenuManagementPage = lazy(() => import("./MenuManagementPage"));
const FixedQuestionsPage = lazy(() => import("./FixedQuestionsPage"));
const PageDescriptionSettingsPage = lazy(
() => import("./PageDescriptionSettingsPage"),
);
const ScheduledTaskRunDetailPage = lazy(() => import("./ScheduledTaskRunDetailPage"));
const ScheduledTasksPage = lazy(() => import("./ScheduledTasksPage"));
const LlmWikiKnowledgePage = lazy(() => import("./LlmWikiKnowledgePage"));
@@ -1381,6 +1384,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="admin/page-descriptions"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PageDescriptionSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
@@ -1,6 +1,10 @@
"use client";
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
import {
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { normalizeKnowledgeRetrievalMode, resolveKnowledgeRetrievalRequestContext, resolveSmartKnowledgeRetrievalAvailability, type KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
@@ -672,6 +676,8 @@ function DeepResearchWorkbenchInner({
const [reportCardRetrievalSkillsOverrides, setReportCardRetrievalSkillsOverrides] = useState<Record<string, string[]>>({});
const [classicMode, setClassicMode] = useState(false);
const retrievalSettings = useOrdinaryKnowledgeRetrievalSettings();
const pageDescriptions = usePageDescriptionsSettings();
const deepResearchPageDescription = pageDescriptions.data?.deep_research;
const [draftRetrievalMode, setDraftRetrievalMode] = useState<KnowledgeRetrievalMode>("classic");
const [draftKnowledgeBaseIds, setDraftKnowledgeBaseIds] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
@@ -3261,7 +3267,10 @@ function DeepResearchWorkbenchInner({
<main className="flex h-full min-w-0 flex-col">
{!session || (isChatSession && collectorEmpty) ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<ResearchWelcome onStart={() => document.getElementById("deep-research-composer")?.focus()} />
<ResearchWelcome
pageDescription={deepResearchPageDescription}
onStart={() => document.getElementById("deep-research-composer")?.focus()}
/>
</div>
) : isChatSession ? (
<>
@@ -3786,12 +3795,22 @@ function StatusBadge({ status }: { status: DeepResearchSession["status"] }) {
return <Badge variant="outline" className={`border-0 px-2 py-0.5 text-[11px] ${tone}`}>{STATUS_LABELS[status]}</Badge>;
}
function ResearchWelcome({ onStart }: { onStart: () => void }) {
function ResearchWelcome({
pageDescription,
onStart,
}: {
pageDescription?: PageHeaderDescription;
onStart: () => void;
}) {
return (
<div className="flex min-h-[440px] flex-col items-center justify-center text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Telescope className="h-7 w-7" /></div>
<h1 className="text-2xl font-semibold tracking-tight">开始一项深度研究</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。</p>
<h1 className="text-2xl font-semibold tracking-tight">
{pageDescription?.title || "开始一项深度研究"}
</h1>
<p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">
{pageDescription?.description || "输入研究课题后,内置的收集员智能体会像普通对话一样理解课题、拆分检索词并联网/知识库检索;完成后确认报告写法,系统会基于收集到的资料在右侧沙箱撰写报告。"}
</p>
<Button className="mt-6" onClick={onStart}><Sparkles className="mr-2 h-4 w-4" />输入研究课题</Button>
</div>
);
@@ -68,6 +68,8 @@ import {
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { useModels } from "@/core/models/hooks";
import { useLocalSettings } from "@/core/settings/hooks";
import type { PageHeaderDescription } from "@/core/system-settings/api";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useRenameThread,
@@ -248,6 +250,8 @@ function GongwenWorkbenchInner() {
const documentQuery = useGongwenDocument(selectedDocumentId);
const actions = useGongwenActions();
const renameGongwenThread = useRenameThread();
const pageDescriptions = usePageDescriptionsSettings();
const gongwenPageDescription = pageDescriptions.data?.gongwen;
const [localSettings] = useLocalSettings();
const { models, isLoading: modelsLoading } = useModels();
const templates = templatesQuery.data ?? [];
@@ -618,6 +622,7 @@ function GongwenWorkbenchInner() {
return <>
{view === "chat" ? (
<GongwenAgentConversationLayout
pageDescription={gongwenPageDescription}
templates={templates}
documents={documents}
documentsLoading={documentsQuery.isLoading}
@@ -755,6 +760,7 @@ function GongwenConversationLayout(props: {
}
function GongwenAgentConversationLayout(props: {
pageDescription?: PageHeaderDescription;
templates: GongwenTemplate[];
documents: GongwenDocument[];
documentsLoading: boolean;
@@ -846,7 +852,7 @@ function GongwenAgentConversationLayout(props: {
scrollButtonClassName="bottom-3"
onClarificationSubmit={props.onClarificationSubmit}
onEditResubmit={props.onEditResubmit}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">开始一份公文工作</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
/> : <div className="flex min-h-0 flex-1 flex-col items-center justify-center px-5 text-center"><div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"><Landmark className="h-7 w-7" /></div><h1 className="text-2xl font-semibold tracking-tight">{props.pageDescription?.title || "开始一份公文工作"}</h1><p className="mt-3 max-w-md text-sm leading-6 text-muted-foreground">{props.pageDescription?.description || "选择模板后由公文智能体逐项收集信息并起草;也可以在下方直接上传 Word,由智能体校审后再由你确认是否修复。"}</p><div className="mt-6 flex flex-wrap justify-center gap-2"><Button onClick={props.onShowTemplates}><FolderOpen className="mr-1.5 h-4 w-4" /> 选择模板</Button><Button variant="outline" onClick={() => document.getElementById("gongwen-composer")?.focus()}><Upload className="mr-1.5 h-4 w-4" /> 在下方上传 Word</Button></div></div>}
<div className="shrink-0 border-t border-border/70 bg-background px-4 py-4 sm:px-6">
<div className="space-y-3 rounded-2xl border border-border bg-card p-3 shadow-[0_8px_30px_rgba(0,0,0,0.06)]">
@@ -3,8 +3,10 @@ import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
import {
isRoundtableCapacityFailure,
RoundtableRoomFullError,
isRoundtableBusyFailure,
isRoundtableRoomFullFailure,
roundtableErrorForDisplay,
roundtableRoomFullError,
} from "@/roundtable-planning/lib/capacity-error";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { reportRoundtableError, stageForRoundtableAgent } from "@/roundtable-planning/api/diagnostics-report";
@@ -12,29 +14,33 @@ import { createInlineReasoningDeltaFormatter } from "@/roundtable-planning/lib/s
export type ThreadIdMap = Record<string, string>;
function roomFullError(
function throwRoundtableRunError(
rawDetail: string,
status: number | null,
context: string,
): RoundtableRoomFullError {
let activeCount = 0;
let maxRooms = 0;
try {
const body = JSON.parse(rawDetail) as {
detail?: { active_count?: number; max_rooms?: number };
active_count?: number;
max_rooms?: number;
};
activeCount = body.detail?.active_count ?? body.active_count ?? 0;
maxRooms = body.detail?.max_rooms ?? body.max_rooms ?? 0;
} catch {
// 包装后的 502 / SSE 文本通常不是纯 JSON;计数仅供诊断,不影响统一提示。
): never {
if (isRoundtableRoomFullFailure(status, rawDetail)) {
throw roundtableRoomFullError(rawDetail, status, context);
}
console.error(`[roundtable-capacity] ${context}`, {
status,
detail: rawDetail,
});
return new RoundtableRoomFullError(activeCount, maxRooms);
throw new Error(
roundtableErrorForDisplay(rawDetail, status) || "会商暂时无法继续,请稍后重试",
);
}
function isClassifiedRoundtableFailure(
status: number | null,
detail: string,
): boolean {
if (status === 409 || status === 429) return true;
if (isRoundtableRoomFullFailure(status, detail) || isRoundtableBusyFailure(status, detail)) {
return true;
}
const displayed = roundtableErrorForDisplay(detail, status);
return displayed != null && displayed !== detail;
}
export interface SeatReadyEvent {
@@ -119,8 +125,8 @@ export async function initMultiAgent(
httpStatus: res.status,
detail: { agentNames: payload.agentNames, mainAgentName: payload.mainAgentName },
});
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
throw roomFullError(text, res.status, "init HTTP request rejected");
if (isClassifiedRoundtableFailure(res.status, text)) {
throwRoundtableRunError(text, res.status, "init HTTP request rejected");
}
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
@@ -215,8 +221,8 @@ export async function initMultiAgent(
stage: "step2_init", event: "init_error_frame",
message: `第二步会商初始化报错:${errorDetail.slice(0, 800)}`,
});
if (isRoundtableCapacityFailure(null, errorDetail)) {
throw roomFullError(errorDetail, null, "init SSE error frame");
if (isClassifiedRoundtableFailure(null, errorDetail)) {
throwRoundtableRunError(errorDetail, null, "init SSE error frame");
}
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
@@ -627,8 +633,8 @@ export async function streamMultiAgent(
detail: { agentType: req.agentType },
});
reported = true;
if (res.status === 409 || isRoundtableCapacityFailure(res.status, text)) {
throw roomFullError(text, res.status, "run/stream HTTP request rejected");
if (isClassifiedRoundtableFailure(res.status, text)) {
throwRoundtableRunError(text, res.status, "run/stream HTTP request rejected");
}
throw new Error(`run/stream failed (${res.status}): ${text}`);
}
@@ -998,9 +1004,14 @@ export async function streamMultiAgent(
agentName: req.displayName || req.agentName,
detail: { agentType: req.agentType, framesBeforeError: frameSeq },
});
if (isRoundtableCapacityFailure(null, result.content)) {
const displayed = roundtableErrorForDisplay(result.content);
if (
isRoundtableRoomFullFailure(null, result.content) ||
isRoundtableBusyFailure(null, result.content) ||
(displayed != null && displayed !== result.content)
) {
reported = true;
throw roomFullError(
throwRoundtableRunError(
result.content,
null,
`run/stream SSE error frame (${req.displayName || req.agentName})`,
@@ -5,6 +5,7 @@ export {
isRoundtableCapacityFailure,
isRoundtableRoomFullError,
RoundtableRoomFullError,
roundtableCardFailure,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/lib/capacity-error";
@@ -20,9 +20,9 @@ import { OptType, writeYLog } from "@/core/log/y-log";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isRoundtableCapacityFailure,
RoundtableRoomFullError,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
RoundtableRoomFullError,
sanitizeRoundtableDialogue,
} from "../lib/capacity-error";
import { reportRoundtableError } from "./diagnostics-report";
import { seatModeToRunParams } from "../lib/seat-mode";
@@ -57,6 +57,15 @@ async function readJson<T>(res: Response, ctx: string): Promise<T> {
logCapacityError(ctx, text);
throw new RoundtableRoomFullError();
}
const displayed = roundtableErrorForDisplay(text, res.status);
if (
res.status === 409 ||
res.status === 429 ||
(displayed != null && displayed !== text)
) {
logCapacityError(ctx, text);
throw new Error(displayed || "会商暂时无法继续,请稍后重试");
}
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
@@ -84,14 +93,15 @@ function withAvatar(nodes: unknown): DispatchChainNode[] {
function mapJob(raw: Record<string, unknown>): RoundtableJob {
const id = String(raw.id ?? "");
const rawError = (raw.error as string | null) ?? null;
if (isRoundtableCapacityFailure(null, rawError)) {
logCapacityError(id, rawError ?? "");
if (rawError && (isRoundtableCapacityFailure(null, rawError) || roundtableErrorForDisplay(rawError) !== rawError)) {
logCapacityError(id, rawError);
}
const dialogues = Array.isArray(raw.dialogues)
? (raw.dialogues as JobDialogue[]).map((dialogue) => {
if (!isRoundtableCapacityFailure(null, dialogue.content)) return dialogue;
const sanitized = sanitizeRoundtableDialogue(dialogue.content);
if (sanitized === dialogue.content) return dialogue;
logCapacityError(id, dialogue.content);
return { ...dialogue, content: ROUNDTABLE_ROOM_FULL_MESSAGE };
return { ...dialogue, content: sanitized };
})
: [];
return {
@@ -25,7 +25,9 @@ import { Sparkles } from "lucide-react";
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
import {
isRoundtableCapacityFailure,
isRoundtableBusyFailure,
isRoundtableRoomFullFailure,
roundtableErrorForDisplay,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/lib/capacity-error";
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
@@ -88,10 +90,13 @@ export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
if (job.status === "done") {
setNotice("后台研讨已完成!可在「历史记录」查看结论与报告");
} else if (job.status === "error") {
const displayed = roundtableErrorForDisplay(job.error);
setNotice(
isRoundtableCapacityFailure(null, job.error)
isRoundtableRoomFullFailure(null, job.error)
? ROUNDTABLE_ROOM_FULL_MESSAGE
: "后台任务执行出错,请在「历史记录」中查看",
: isRoundtableBusyFailure(null, job.error) || (displayed && displayed !== job.error)
? displayed || ROUNDTABLE_ROOM_FULL_MESSAGE
: "后台任务执行出错,请在「历史记录」中查看",
);
}
// cancelled:用户主动取消,不提示。
@@ -82,6 +82,10 @@ import type {
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { chainToDagPlan } from "@/roundtable-planning/lib/dag-types";
import {
resolveDagMilestoneStates,
resolveLiveDagNodeStates,
} from "@/roundtable-planning/lib/dag-flow-state";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
@@ -368,61 +372,29 @@ export function Step2Panel(props: Step2PanelProps) {
},
[agentNameToRole],
);
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
const effectiveDagNodeStates = useMemo(() => {
const merged: Record<string, ChainNodeState> = {};
const deliveredNames = new Set(
step2RoundtableDialogues
.filter(
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
}
// 串行业务链(chain 模式):流式中的席位气泡点亮为 active(dagNodeStates 只在 dag
// 编排里维护,chain 靠对话推导;完成后由上面的「子智能体交付」规则置 done)。
if (orchestrationMode === "chain") {
const streamingSenders = new Set(
step2RoundtableDialogues
.filter((d) => d.streaming)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (streamingSenders.has(meta.displayName)) merged[aid.toLowerCase()] = "active";
}
}
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
for (const s of parallelBatch) {
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
}
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
return merged;
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch, orchestrationMode]);
// 流程图跟当前发言走:席位气泡已经在输出时,节点就是进行中。
// dagNodeStates 里残留的 pending 不能把这个实时状态盖掉,否则总控会一直转圈。
const effectiveDagNodeStates = useMemo(
() =>
resolveLiveDagNodeStates({
agentNameToRole,
dialogues: step2RoundtableDialogues,
dagNodeStates,
parallelBatch,
}),
[dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch],
);
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(effectiveDagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
const coordinator: ChainNodeState = anyActive
? "done"
: isOrchestrating
? "active"
: anyStarted
? "done"
: "pending";
const consensus: ChainNodeState = hasConsensus
? "done"
: allDone
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
const { dagCoordinatorState, dagConsensusState } = useMemo(
() =>
resolveDagMilestoneStates({
seatStates: effectiveDagNodeStates,
dialogues: step2RoundtableDialogues,
isOrchestrating,
hasConsensus,
}),
[effectiveDagNodeStates, step2RoundtableDialogues, isOrchestrating, hasConsensus],
);
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
@@ -22,6 +22,7 @@ import {
} from "@/components/workspace/artifacts";
import {
isRoundtableRoomFullError,
roundtableCardFailure,
type RoundtableRoomFullError,
} from "@/roundtable-planning/api/rooms";
import {
@@ -2356,7 +2357,8 @@ export function useStep2Orchestration(
continue;
}
console.error(`[special:${a.agentId}] error:`, err);
finalizeDialogue(a.bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(a.bubbleId, failure.content || "(已停止)", failure.badge);
return { content: "", aborted: false, invalid: true };
}
}
@@ -2438,10 +2440,11 @@ export function useStep2Orchestration(
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -2599,7 +2602,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`研讨异常: ${(err as Error).message}`);
triggerToast(`研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -2730,10 +2733,11 @@ export function useStep2Orchestration(
finishedAllSeats = false;
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -2838,7 +2842,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(subBubbleId, failure.content || "(已停止)", failure.badge);
seatInvalid = true;
}
} finally {
@@ -2949,7 +2954,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error("chain closeout error:", err);
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(closeoutBubbleId, failure.content || "(已停止)", failure.badge);
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
@@ -2961,7 +2967,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`链条研讨异常: ${(err as Error).message}`);
triggerToast(`链条研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -3138,10 +3144,11 @@ export function useStep2Orchestration(
finishedAllStages = false;
break;
}
const failure = roundtableCardFailure(err);
finalizeDialogue(
leaderBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
failure.content || "(已停止)",
failure.badge,
);
throw err;
}
@@ -3297,7 +3304,8 @@ export function useStep2Orchestration(
throw err;
} else {
console.error(`[dag-special:${agentId}] error:`, err);
finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
const failure = roundtableCardFailure(err);
finalizeDialogue(bubbleId, failure.content || "(已停止)", failure.badge);
markBatchStatus(agentId, "error");
}
return { seatName, invalid: true, content: "" };
@@ -3386,7 +3394,7 @@ export function useStep2Orchestration(
handleRoomFull(err);
return;
}
triggerToast(`依赖编排研讨异常: ${(err as Error).message}`);
triggerToast(`依赖编排研讨异常: ${roundtableCardFailure(err).content.replace(/^\(错误\) /, "")}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
@@ -2,33 +2,108 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isRoundtableBusyFailure,
isRoundtableCapacityFailure,
roomDecisionMessage,
roundtableCardFailure,
roundtableErrorForDisplay,
RoundtableRoomFullError,
ROUNDTABLE_BUSY_MESSAGE,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "./capacity-error";
sanitizeRoundtableDialogue,
} from "./capacity-error.ts";
test("recognizes a direct HTTP 429", () => {
assert.equal(isRoundtableCapacityFailure(429, ""), true);
test("a direct HTTP 429 is model or worker busy, not a full room", () => {
assert.equal(isRoundtableCapacityFailure(429, ""), false);
assert.equal(isRoundtableBusyFailure(429, ""), true);
assert.equal(roundtableErrorForDisplay("", 429), ROUNDTABLE_BUSY_MESSAGE);
});
test("recognizes a 429 wrapped by the internal run stream gateway", () => {
test("a 429 wrapped by the internal run stream stays busy and hides the upstream URL", () => {
const detail =
"[502] upstream http://127.0.0.1:8001/api/threads/thread-secret/runs/stream returned HTTP 429: busy";
assert.equal(isRoundtableCapacityFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_ROOM_FULL_MESSAGE);
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
});
test("recognizes local concurrency and worker saturation messages", () => {
test("recognizes local concurrency and worker saturation messages as busy", () => {
const samples = [
"This account has reached the concurrency limit for the selected model",
"执行节点繁忙,请稍后重试",
"等待执行超时,请稍后重试",
"selected model is busy",
];
for (const detail of samples) {
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), true);
assert.equal(roundtableErrorForDisplay(detail, 502), ROUNDTABLE_BUSY_MESSAGE);
}
});
test("only the room-full code opens the room-full message", () => {
const detail = JSON.stringify({
detail: { code: "ROUNDTABLE_ROOM_FULL", message: "当前会商房间暂时已满,请稍候片刻后再试。" },
});
assert.equal(isRoundtableCapacityFailure(409, detail), true);
assert.equal(isRoundtableBusyFailure(409, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 409), ROUNDTABLE_ROOM_FULL_MESSAGE);
assert.equal(roomDecisionMessage("ROUNDTABLE_ROOM_FULL"), ROUNDTABLE_ROOM_FULL_MESSAGE);
});
test("lease expiry and max hold keep their own messages", () => {
const lease = JSON.stringify({ code: "ROOM_LEASE_EXPIRED" });
const maxHold = JSON.stringify({ code: "ROOM_MAX_HOLD_EXCEEDED" });
assert.equal(isRoundtableCapacityFailure(409, lease), false);
assert.equal(isRoundtableCapacityFailure(409, maxHold), false);
assert.equal(roundtableErrorForDisplay(lease, 409), roomDecisionMessage("ROOM_LEASE_EXPIRED"));
assert.equal(
isRoundtableCapacityFailure(502, "This account has reached the concurrency limit for the selected model"),
true,
roundtableErrorForDisplay(maxHold, 409),
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
assert.equal(isRoundtableCapacityFailure(502, "执行节点繁忙,请稍后重试"), true);
assert.equal(isRoundtableCapacityFailure(502, "等待执行超时,请稍后重试"), true);
});
test("a plain HTTP 409 is not treated as a full room", () => {
const detail = "thread already has an active run";
assert.equal(isRoundtableCapacityFailure(409, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 409), detail);
});
test("does not hide an unrelated gateway error", () => {
const detail = "upstream returned HTTP 502: connection refused";
assert.equal(isRoundtableCapacityFailure(502, detail), false);
assert.equal(isRoundtableBusyFailure(502, detail), false);
assert.equal(roundtableErrorForDisplay(detail, 502), detail);
});
test("long seat deliveries that mention concurrency stay intact", () => {
const prose = `${"席位认为当前方案需要同时考虑资源并发和预算限制,建议先完成情报核对,再决定是否扩大行动范围。".repeat(20)}`;
assert.ok(prose.length > 800);
assert.equal(sanitizeRoundtableDialogue(prose), prose);
});
test("coordinator card does not relabel other failures as a full room", () => {
const lease = roundtableCardFailure(
new Error(JSON.stringify({ code: "ROOM_LEASE_EXPIRED" })),
);
assert.equal(lease.roomFull, false);
assert.equal(lease.badge, "调用失败");
assert.equal(lease.content.includes("当前房间已满"), false);
assert.equal(lease.content.includes("会商连接已断开"), true);
const busy = roundtableCardFailure(new Error("执行节点繁忙,请稍后重试"));
assert.equal(busy.badge, "调用失败");
assert.equal(busy.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
assert.equal(busy.content.includes(ROUNDTABLE_BUSY_MESSAGE), true);
const full = roundtableCardFailure(new RoundtableRoomFullError());
assert.equal(full.roomFull, true);
assert.equal(full.badge, "已停止");
assert.equal(full.content.includes("调用失败"), false);
assert.equal(full.content.includes(ROUNDTABLE_ROOM_FULL_MESSAGE), false);
});
test("error-shaped dialogue with an upstream address is replaced", () => {
const detail = "[502] upstream http://10.0.0.8/api/threads/abc/runs/stream returned HTTP 500";
assert.equal(sanitizeRoundtableDialogue(detail), "会商暂时无法继续,请稍后重试");
});
@@ -1,8 +1,63 @@
/** 会商容量不足时唯一允许展示给业务用户的文案。 */
/** 会商名额真正用尽时,唯一允许展示给业务用户的文案。 */
export const ROUNDTABLE_ROOM_FULL_MESSAGE = "当前房间已满,请稍后";
/** 模型并发或执行节点忙,不是会商房间名额。 */
export const ROUNDTABLE_BUSY_MESSAGE = "当前模型或执行节点繁忙,请稍后重试";
const GENERIC_ROUNDTABLE_FAILURE = "会商暂时无法继续,请稍后重试";
const ROOM_DECISION_MESSAGES: Record<string, string> = {
ROUNDTABLE_ROOM_FULL: ROUNDTABLE_ROOM_FULL_MESSAGE,
ROOM_LEASE_EXPIRED: "会商连接已断开,请重新恢复研讨。",
ROOM_MAX_HOLD_EXCEEDED:
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
ROOM_STORE_UNAVAILABLE: "会商并发服务暂不可用,请稍后再试。",
ROOM_SESSION_REQUIRED: "会商会话标识缺失,请刷新页面后重试。",
DRAFT_REQUIRED: "进入第二步需要先保存会商草稿",
};
export function roomDecisionMessage(code: string | null | undefined): string | null {
if (!code) return null;
return ROOM_DECISION_MESSAGES[code] ?? null;
}
function isBusyText(text: string): boolean {
return (
/returned\s+http\s+429\b/i.test(text) ||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
/\btoo\s+many\s+requests\b/i.test(text) ||
/\bconcurrency\s+limit\b/i.test(text) ||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
/执行节点繁忙|等待执行超时|模型繁忙/.test(text)
);
}
function readRoomCode(detail: string): string {
const match = detail.match(/["']code["']\s*:\s*["']([A-Z0-9_]+)["']/);
if (match?.[1]) return match[1];
try {
const body = JSON.parse(detail) as {
code?: unknown;
detail?: { code?: unknown };
};
const code = body.detail?.code ?? body.code;
return typeof code === "string" ? code : "";
} catch {
return "";
}
}
function isRoomFullText(text: string, code: string): boolean {
if (code === "ROUNDTABLE_ROOM_FULL") return true;
return (
text === ROUNDTABLE_ROOM_FULL_MESSAGE ||
text.includes("当前会商房间暂时已满")
);
}
/**
* 会商运行过程中遇到的容量错误。
* 会商运行过程中遇到的名额已满。
*
* 原始响应只能写入控制台 / 诊断日志,不能放进 message,避免内网地址、thread id
* 等实现细节经现有的 toast / 对话气泡 / 错误面板泄露到页面。
@@ -27,37 +82,133 @@ export function isRoundtableRoomFullError(error: unknown): error is RoundtableRo
}
/**
* Gateway 会把内部 `/runs/stream` 的 429 包装成 502 或 SSE error 帧,因此不能只看
* 最外层 HTTP 状态。这里同时识别包装后的状态文本和本地并发闸门的稳定错误文案。
* 总控/席位卡片上的失败展示。
* 名额用尽不写进卡片(由房间弹窗说明,标签也不再是「调用失败」)。
* 租约断开、模型繁忙仍标「调用失败」,但不得改写成房间已满文案。
*/
export function roundtableCardFailure(error: unknown): {
roomFull: boolean;
badge: string;
content: string;
} {
if (isRoundtableRoomFullError(error)) {
return {
roomFull: true,
badge: "已停止",
content: "",
};
}
const raw = error instanceof Error ? error.message : String(error ?? "");
const displayed = roundtableErrorForDisplay(raw);
const message =
displayed && displayed !== ROUNDTABLE_ROOM_FULL_MESSAGE
? displayed
: "会商暂时无法继续,请稍后重试";
return {
roomFull: false,
badge: "调用失败",
content: `(错误) ${message}`,
};
}
/** 只有会商房间名额用尽才返回 true。模型繁忙、租约丢失、最长占用都不是。 */
export function isRoundtableRoomFullFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
const text = (detail ?? "").trim();
if (!text && status !== 409) return false;
return isRoomFullText(text, readRoomCode(text));
}
/** 模型并发或执行节点忙。调用方不得据此释放会商房间。 */
export function isRoundtableBusyFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
if (isRoundtableRoomFullFailure(status, detail)) return false;
if (status === 429) return true;
const text = (detail ?? "").trim();
return text ? isBusyText(text) : false;
}
/**
* 旧名保留给调用方。语义收窄为「房间名额已满」,不再把 429 / 节点繁忙算进去。
*/
export function isRoundtableCapacityFailure(
status: number | null | undefined,
detail: string | null | undefined,
): boolean {
if (status === 429) return true;
const text = (detail ?? "").trim();
if (!text) return false;
if (text === ROUNDTABLE_ROOM_FULL_MESSAGE) return true;
return (
/returned\s+http\s+429\b/i.test(text) ||
/(?:status(?:_code)?|http(?:\s+status)?)\s*["':=]*\s*429\b/i.test(text) ||
/\b429\s+(?:too\s+many\s+requests|resource\s+exhausted)\b/i.test(text) ||
/\btoo\s+many\s+requests\b/i.test(text) ||
/\bconcurrency\s+limit\b/i.test(text) ||
/\bselected\s+model\s+is\s+busy\b/i.test(text) ||
/执行节点繁忙|等待执行超时|并发(?:数|量)?.*(?:上限|限制)|模型繁忙/.test(text)
);
return isRoundtableRoomFullFailure(status, detail);
}
/** 页面可展示的错误详情;容量错误永远折叠为统一文案。 */
function readCounts(detail: string): { activeCount: number; maxRooms: number } {
try {
const body = JSON.parse(detail) as {
detail?: { active_count?: number; max_rooms?: number };
active_count?: number;
max_rooms?: number;
};
return {
activeCount: body.detail?.active_count ?? body.active_count ?? 0,
maxRooms: body.detail?.max_rooms ?? body.max_rooms ?? 0,
};
} catch {
return { activeCount: 0, maxRooms: 0 };
}
}
export function roundtableRoomFullError(
rawDetail: string,
status: number | null,
context: string,
): RoundtableRoomFullError {
const counts = readCounts(rawDetail);
console.error(`[roundtable-capacity] ${context}`, {
status,
detail: rawDetail,
});
return new RoundtableRoomFullError(counts.activeCount, counts.maxRooms);
}
function containsTransportDetail(text: string): boolean {
return /https?:\/\//i.test(text) || /\/api\/threads\//i.test(text);
}
/** 页面可展示的错误详情。房间已满、节点繁忙和已知房间错误都折叠成固定文案。 */
export function roundtableErrorForDisplay(
detail: string | null | undefined,
status?: number | null,
): string | null {
if (detail == null) return null;
return isRoundtableCapacityFailure(status, detail)
? ROUNDTABLE_ROOM_FULL_MESSAGE
: detail;
if (detail == null && status !== 429) return null;
const text = (detail ?? "").trim();
if (isRoundtableRoomFullFailure(status, text)) return ROUNDTABLE_ROOM_FULL_MESSAGE;
if (isRoundtableBusyFailure(status, text)) return ROUNDTABLE_BUSY_MESSAGE;
if (!text) return detail ?? null;
const code = readRoomCode(text);
const known = ROOM_DECISION_MESSAGES[code];
if (known) return known;
if (containsTransportDetail(text)) return GENERIC_ROUNDTABLE_FAILURE;
return detail;
}
/**
* 只改写明显是错误载荷的短文本。正常席位交付即使提到「并发」「限制」也保持原文。
*/
export function sanitizeRoundtableDialogue(content: string): string {
const text = content.trim();
if (!text) return content;
const errorShaped =
text.length <= 800 ||
/^\[\d{3}\]/.test(text) ||
text.includes("returned HTTP") ||
text.includes("ROUNDTABLE_ROOM_") ||
text.includes("ROOM_LEASE_") ||
text.includes("ROOM_MAX_HOLD_") ||
text.includes("ROOM_STORE_") ||
text.includes("ROOM_SESSION_");
if (!errorShaped) return content;
const display = roundtableErrorForDisplay(text);
if (!display || display === text) return content;
return display;
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDagMilestoneStates, resolveLiveDagNodeStates } from "./dag-flow-state.ts";
const roles = {
"seat-01": { displayName: "测试01" },
"seat-02": { displayName: "测试02" },
};
test("a streaming first seat takes the in-progress marker off the coordinator", () => {
const seats = resolveLiveDagNodeStates({
agentNameToRole: roles,
dialogues: [
{ sender: "总控协调", streaming: false, confidence: "派活说明", content: "派给测试01" },
{ sender: "测试01", streaming: true, confidence: "调度中", content: "" },
],
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
parallelBatch: [],
});
assert.equal(seats["seat-01"], "active");
assert.equal(seats["seat-02"], "pending");
const milestones = resolveDagMilestoneStates({
seatStates: seats,
dialogues: [
{ sender: "总控协调", streaming: false },
{ sender: "测试01", streaming: true },
],
isOrchestrating: true,
hasConsensus: false,
});
assert.equal(milestones.coordinator, "done");
});
test("the coordinator stays in progress while it is still the one speaking", () => {
const seats = resolveLiveDagNodeStates({
agentNameToRole: roles,
dialogues: [{ sender: "总控协调", streaming: true, confidence: "派活中", content: "" }],
dagNodeStates: { "seat-01": "pending", "seat-02": "pending" },
parallelBatch: [],
});
const milestones = resolveDagMilestoneStates({
seatStates: seats,
dialogues: [{ sender: "总控协调", streaming: true }],
isOrchestrating: true,
hasConsensus: false,
});
assert.equal(milestones.coordinator, "active");
assert.equal(seats["seat-01"], "pending");
});
@@ -0,0 +1,92 @@
import type { ChainNodeState } from "./job-types.ts";
/** 与 seat-filter 的 COORDINATOR_SENDER 相同。这里单独写出,避免测试去拉前端模块图。 */
const COORDINATOR_SENDER = "总控协调";
export interface DagFlowDialogue {
sender?: string;
streaming?: boolean;
confidence?: string;
content?: string;
}
export interface DagFlowSeatMeta {
displayName: string;
}
/**
* 流程图节点状态。
*
* 实时发言以对话气泡为准:席位气泡已经在流式输出时,节点必须是进行中,
* 不能被一份还停在 pending 的 dagNodeStates 盖回去。总控只在自己发言、
* 且还没有任何席位进行中时保持进行中。
*/
export function resolveLiveDagNodeStates(input: {
agentNameToRole: Record<string, DagFlowSeatMeta>;
dialogues: readonly DagFlowDialogue[];
dagNodeStates: Record<string, ChainNodeState>;
parallelBatch: readonly { agentId: string; status: string }[];
}): Record<string, ChainNodeState> {
const merged: Record<string, ChainNodeState> = {};
const keyOf = (value: string) => value.trim().toLowerCase();
const streamingSenders = new Set(
input.dialogues.filter((d) => d.streaming).map((d) => keyOf(String(d.sender ?? ""))),
);
const deliveredSenders = new Set(
input.dialogues
.filter(
(d) =>
d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => keyOf(String(d.sender ?? ""))),
);
const matches = (agentId: string, displayName: string, senders: Set<string>) =>
senders.has(keyOf(displayName)) || senders.has(keyOf(agentId));
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
if (matches(agentId, meta.displayName, deliveredSenders)) {
merged[keyOf(agentId)] = "done";
}
}
for (const seat of input.parallelBatch) {
merged[keyOf(seat.agentId)] = seat.status === "done" ? "done" : "active";
}
for (const [agentId, state] of Object.entries(input.dagNodeStates)) {
const key = keyOf(agentId);
if (state === "pending" && (merged[key] === "active" || merged[key] === "done")) {
continue;
}
merged[key] = state;
}
for (const [agentId, meta] of Object.entries(input.agentNameToRole)) {
if (matches(agentId, meta.displayName, streamingSenders)) {
merged[keyOf(agentId)] = "active";
}
}
return merged;
}
export function resolveDagMilestoneStates(input: {
seatStates: Record<string, ChainNodeState>;
dialogues: readonly DagFlowDialogue[];
isOrchestrating: boolean;
hasConsensus: boolean;
}): { coordinator: ChainNodeState; consensus: ChainNodeState } {
const states = Object.values(input.seatStates);
const anyActive = states.includes("active");
const anyStarted = states.some((state) => state !== "pending");
const coordinatorStreaming = input.dialogues.some(
(d) => d.streaming && String(d.sender ?? "") === COORDINATOR_SENDER,
);
const coordinator: ChainNodeState = anyActive
? "done"
: coordinatorStreaming || (input.isOrchestrating && !anyStarted)
? "active"
: anyStarted
? "done"
: "pending";
const allDone = states.length > 0 && states.every((state) => state === "done");
const consensus: ChainNodeState = input.hasConsensus ? "done" : allDone ? "active" : "pending";
return { coordinator, consensus };
}
@@ -68,6 +68,7 @@ import {
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useRouteNavigate } from "@/core/route-base";
import { usePageDescriptionsSettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
@@ -101,6 +102,8 @@ export default function BusinessChainListPage() {
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// 管理员:额外多一个「全部链条」筛选,且可编辑 / 发布 / 删除任意账号的链条。
const isAdmin = getStoredAuth()?.system_role === "admin";
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.business_chains;
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
@@ -333,13 +336,10 @@ export default function BusinessChainListPage() {
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h1 className="flex items-center text-lg font-bold tracking-tight text-zinc-900 dark:text-[#E8EDF5]">
业务链条配置
<span className="ml-2 rounded-md border border-[#CF1322]/30 bg-[#FFF1F0] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#CF1322] dark:border-[#A8071A]/40 dark:bg-[#A8071A]/20 dark:text-[#FF7875]">
Orchestrator v2
</span>
{pageDescription?.title || "业务链条配置"}
</h1>
<p className="mt-1 max-w-xl text-xs text-zinc-500 dark:text-[#6E88B0]">
可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。
{pageDescription?.description || "可复用的有序智能体编排。点击卡片「设置」更新节点参数;「发布」后所有账号都能在「公共」里查看 / 选用。"}
</p>
</div>
@@ -164,6 +164,10 @@ import {
releaseRoundtableRoom,
ROUNDTABLE_ROOM_FULL_MESSAGE,
} from "@/roundtable-planning/api/rooms";
import {
isRoundtableRoomFullFailure,
roomDecisionMessage,
} from "@/roundtable-planning/lib/capacity-error";
import { isRoundtableRoomExecutionActive } from "@/roundtable-planning/lib/room-lifecycle";
import {
Dialog,
@@ -492,10 +496,13 @@ export default function RoundtablePlanningPage({
roomSessionId: roomSessionIdRef.current,
});
const roomHoldRef = useRef(false);
const roomBlockRef = useRef<string | null>(null);
const roomReleasePromiseRef = useRef<Promise<unknown> | null>(null);
const roomAcquirePromiseRef = useRef<Promise<boolean> | null>(null);
const roomHandoffRef = useRef(false);
const roomEffectGenRef = useRef(0);
const [roomFull, setRoomFull] = useState(false);
const [roomLeaseEpoch, setRoomLeaseEpoch] = useState(0);
// 由 prefill.businessCode 解析出的「自动选用业务链」结果;有值时「进入研讨」跳过选链弹窗直接进 Step 2。
const [autoChainResult, setAutoChainResult] = useState<SeatSelectionResult | null>(null);
// 固定业务链(8BF 等独立页面)的解析状态。不能只以 autoChainResult 是否为空判断:
@@ -1347,14 +1354,30 @@ export default function RoundtablePlanningPage({
const taskId = draftStorageTaskId ?? null;
roomSessionIdRef.current = createRoomSessionId();
const roomSessionId = roomSessionIdRef.current;
roomBlockRef.current = null;
roomScopeRef.current = { draftId, taskId, roomSessionId };
const acquirePromise = (async () => {
try {
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
if (!decision.ok) {
setRoomFull(true);
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
} else {
triggerToast(
decision.message ||
roomDecisionMessage(decision.code) ||
"暂时不能进入第二步",
);
}
return false;
}
roomBlockRef.current = null;
roomHoldRef.current = true;
return true;
} catch (error) {
@@ -1371,6 +1394,85 @@ export default function RoundtablePlanningPage({
},
[drafts, draftStorageTaskId, triggerToast],
);
const reacquireLiveRoom = useCallback(async (force = false) => {
if (!force && roomHoldRef.current) return true;
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
roomHoldRef.current = false;
triggerToast(
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
return false;
}
if (roomAcquirePromiseRef.current) return roomAcquirePromiseRef.current;
const pendingRelease = roomReleasePromiseRef.current;
if (pendingRelease) {
try {
await pendingRelease;
} catch {
// 释放是尽力而为,失败也不阻止用户重新占用同一个房间。
}
}
const scope = roomScopeRef.current;
if (!scope.draftId || !scope.roomSessionId) {
triggerToast("会商草稿尚未保存,暂时不能恢复");
return false;
}
const { draftId, taskId, roomSessionId } = scope;
const acquirePromise = (async () => {
try {
const decision = await acquireRoundtableRoom({ draftId, taskId, roomSessionId });
if (!decision.ok) {
roomHoldRef.current = false;
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
} else {
triggerToast(
decision.message ||
roomDecisionMessage(decision.code) ||
"暂时不能恢复会商",
);
}
return false;
}
roomBlockRef.current = null;
roomHoldRef.current = true;
drafts.loadedFromDraftRef.current = false;
setRoomLeaseEpoch((epoch) => epoch + 1);
return true;
} catch (error) {
roomHoldRef.current = false;
triggerToast(
`无法确认会商房间:${error instanceof Error ? error.message : "请稍后再试"}`,
);
return false;
} finally {
roomAcquirePromiseRef.current = null;
}
})();
roomAcquirePromiseRef.current = acquirePromise;
return acquirePromise;
}, [drafts, triggerToast]);
const resumeLiveRoundtable = useCallback(() => {
void (async () => {
if (roomBlockRef.current === "ROOM_MAX_HOLD_EXCEEDED") {
triggerToast(
roomDecisionMessage("ROOM_MAX_HOLD_EXCEEDED") ||
"本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
);
return;
}
const admitted = await reacquireLiveRoom(true);
if (!admitted) return;
step2.resumeRoundtable();
})();
}, [reacquireLiveRoom, step2, triggerToast]);
const handleConfirmAgents = useCallback(
(result: SeatSelectionResult) => {
void (async () => {
@@ -2320,16 +2422,31 @@ export default function RoundtablePlanningPage({
return;
}
roomHoldRef.current = false;
if (decision.code === "ROOM_MAX_HOLD_EXCEEDED") {
roomBlockRef.current = decision.code;
}
step2.stopOrchestration();
step2.setIsRoundTableRunning(false);
if (
decision.code === "ROUNDTABLE_ROOM_FULL" ||
isRoundtableRoomFullFailure(409, decision.message)
) {
setRoomFull(true);
}
triggerToast(
decision.message || "本次会商房间已释放,研讨已停止。记录仍留在当前页。",
);
void releaseRoundtableRoom({
const releasePromise = releaseRoundtableRoom({
draftId,
taskId: scope.taskId,
roomSessionId: scope.roomSessionId,
}).catch(() => {});
roomReleasePromiseRef.current = releasePromise;
void releasePromise.finally(() => {
if (roomReleasePromiseRef.current === releasePromise) {
roomReleasePromiseRef.current = null;
}
});
})
.catch(() => {
// 心跳请求失败只能说明本次续租请求没有得到响应,不能据此终止仍在
@@ -2369,7 +2486,9 @@ export default function RoundtablePlanningPage({
currentStep,
step2.hasConsensus,
step2.stopOrchestration,
step2.setIsRoundTableRunning,
triggerToast,
roomLeaseEpoch,
]);
// 研讨一启动(真正进入生成)就确保草稿存在:自动挂起 / beforeunload beacon 需要
// draftId 同步可取,历史下拉的「进行中」徽标也按 draftId 关联。ensureDraft 幂等。
@@ -2891,7 +3010,7 @@ export default function RoundtablePlanningPage({
openArtifactFromStep={step2.openArtifactFromStep}
openArtifactPreview={step2.openArtifactPreview}
onPauseRoundtable={step2.pauseRoundtable}
onResumeRoundtable={step2.resumeRoundtable}
onResumeRoundtable={resumeLiveRoundtable}
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
+178 -19
View File
@@ -1,5 +1,11 @@
import { Bell, ChevronDown, ListTree, User } from "lucide-react";
import { useState } from "react";
import {
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
@@ -19,8 +25,16 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { AppBrandImage } from "@/core/branding/app-brand-image";
import {
getHeaderTitlePreview,
subscribeHeaderTitlePreview,
} from "@/core/branding/header-title-preview";
import {
getLightRedNavigationPreview,
subscribeLightRedNavigationPreview,
} from "@/core/branding/light-red-navigation-preview";
import { isWujieEnv } from "@/core/embed/wujie";
import { readRuntimeEnv } from "@/env";
import { isLightRedTopNavEnabled, readRuntimeEnv } from "@/env";
import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions";
import {
@@ -31,10 +45,18 @@ import {
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { useDisplayDesensitizer } from "@/core/sensitive-words/use-display-desensitizer";
import { useAppSystemName } from "@/core/system-settings/hooks";
import { resolveAppIdentityAssetUrl } from "@/core/system-settings/api";
import { useAppIdentitySettings } from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "../contexts/ThemeContext";
import {
calculateHeaderTitleFit,
HEADER_TITLE_MAX_FONT_SIZE,
HEADER_TITLE_MAX_LETTER_SPACING,
resolveHeaderTitleFit,
type HeaderTitleFit,
} from "./header-title-fit";
const iconButtonClass = (isDark: boolean, isPaleLightTheme: boolean, isCustomTheme: boolean) =>
cn(
@@ -69,10 +91,49 @@ const Header = ({ collapsed }: HeaderProps) => {
: isPaleLightTheme
? "text-[var(--foreground)]"
: "text-white";
const darkLogoPosition =
const isLightRedTopNav = isLightRedTopNavEnabled(theme);
const topNavLogoPosition =
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
const isDarkLogoOnLeft = darkLogoPosition === "left";
const systemName = useAppSystemName("CM业务中心");
const isTopNavLogoOnLeft = topNavLogoPosition === "left";
const { data: appIdentity } = useAppIdentitySettings();
const headerTitlePreview = useSyncExternalStore(
subscribeHeaderTitlePreview,
getHeaderTitlePreview,
getHeaderTitlePreview,
);
const lightRedNavigationPreview = useSyncExternalStore(
subscribeLightRedNavigationPreview,
getLightRedNavigationPreview,
getLightRedNavigationPreview,
);
const systemName = appIdentity?.system_name.trim() || "CM业务中心";
const headerTitleFontMode =
headerTitlePreview?.mode ??
(appIdentity?.header_title_font_mode === "fixed" ? "fixed" : "adaptive");
const headerTitleFontSize =
headerTitlePreview?.fontSize ??
appIdentity?.header_title_font_size ??
HEADER_TITLE_MAX_FONT_SIZE;
const savedLightRedHeaderBackgroundUrl = resolveAppIdentityAssetUrl(
appIdentity?.light_red_header_background_url,
);
const savedLightRedNavActiveUrl = resolveAppIdentityAssetUrl(
appIdentity?.light_red_nav_active_url,
);
const lightRedHeaderBackgroundUrl = lightRedNavigationPreview
? lightRedNavigationPreview.headerBackgroundUrl
: savedLightRedHeaderBackgroundUrl;
const lightRedNavActiveUrl = lightRedNavigationPreview
? lightRedNavigationPreview.navActiveUrl
: savedLightRedNavActiveUrl;
const lightRedArtworkStyle = {
"--light-red-top-nav-background-image": lightRedHeaderBackgroundUrl
? `url("${lightRedHeaderBackgroundUrl}")`
: undefined,
"--light-red-nav-active-background-image": lightRedNavActiveUrl
? `url("${lightRedNavActiveUrl}")`
: undefined,
} as CSSProperties;
const [notificationOpen, setNotificationOpen] = useState(false);
const { data: unreadCount = 0 } = useUnreadCount();
const navigate = useNavigate();
@@ -82,16 +143,94 @@ const Header = ({ collapsed }: HeaderProps) => {
const isAdmin = !auth || auth.system_role === "admin";
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
const desensitizeText = useDisplayDesensitizer();
const displaySystemName = desensitizeText(systemName);
const centerTitleRef = useRef<HTMLDivElement>(null);
const centerTitleMeasureRef = useRef<HTMLSpanElement>(null);
const [centerTitleFit, setCenterTitleFit] = useState<HeaderTitleFit>({
fontSize: HEADER_TITLE_MAX_FONT_SIZE,
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
lines: 1,
});
const renderedCenterTitleFit = resolveHeaderTitleFit({
mode: headerTitleFontMode,
fixedFontSize: headerTitleFontSize,
adaptiveFit: centerTitleFit,
});
const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || "";
const displayName = liveAccount.displayName;
// 深色侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
const showTopNav = isDark && !isWujieEnv();
// 深色与可选浅红色皮肤共用同一套导航结构;两者只替换背景与选中态配色。
// 侧栏收起只影响左侧导航,不应让顶栏回退到浅色的图标布局。
const showTopNav = (isDark || isLightRedTopNav) && !isWujieEnv();
// 浅红主题继续使用原浅色侧栏承载三级菜单,避免与顶部横栏重复。
const showThirdSubNav = isDark && !isWujieEnv();
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isWujieEnv();
const showLightTopNav = !isDark && !isLightRedTopNav && !isWujieEnv();
useLayoutEffect(() => {
if (!showTopNav || headerTitleFontMode === "fixed") {
return;
}
const title = centerTitleRef.current;
const measure = centerTitleMeasureRef.current;
if (!title || !measure) {
return;
}
let disposed = false;
const updateTitleFit = () => {
if (disposed) {
return;
}
const titleStyle = window.getComputedStyle(title);
const horizontalPadding =
Number.parseFloat(titleStyle.paddingLeft) +
Number.parseFloat(titleStyle.paddingRight);
const logo = title.querySelector<HTMLElement>(".header-center-title__logo");
const logoWidth = logo?.getBoundingClientRect().width ?? 0;
const gap = logoWidth > 0 ? Number.parseFloat(titleStyle.columnGap) || 0 : 0;
const availableWidth = Math.max(
1,
title.clientWidth - horizontalPadding - logoWidth - gap,
);
const nextFit = calculateHeaderTitleFit({
availableWidth,
naturalWidth: measure.getBoundingClientRect().width,
characterCount: Array.from(displaySystemName).length,
});
setCenterTitleFit((currentFit) =>
currentFit.fontSize === nextFit.fontSize &&
currentFit.letterSpacing === nextFit.letterSpacing &&
currentFit.lines === nextFit.lines
? currentFit
: nextFit,
);
};
updateTitleFit();
const resizeObserver = new ResizeObserver(updateTitleFit);
resizeObserver.observe(title);
void document.fonts?.ready.then(updateTitleFit);
return () => {
disposed = true;
resizeObserver.disconnect();
};
}, [
displaySystemName,
headerTitleFontMode,
showTopNav,
topNavLogoPosition,
]);
const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
@@ -272,17 +411,23 @@ const Header = ({ collapsed }: HeaderProps) => {
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
<header
className={cn(
"strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4",
isLightRedTopNav && "strategy-app-header--light-red-top-nav",
)}
style={isLightRedTopNav ? lightRedArtworkStyle : undefined}
>
{showTopNav ? (
<>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
<div
className={cn(
"flex h-full min-w-0 flex-1 items-center",
isDarkLogoOnLeft ? "justify-between" : "justify-end",
isTopNavLogoOnLeft ? "justify-between" : "justify-end",
)}
>
{isDarkLogoOnLeft && (
{isTopNavLogoOnLeft && (
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-950/55"
title="系统 Logo"
@@ -304,15 +449,29 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{/* 中:系统 logo + 名称,落在背景图发光基座上。 */}
<div className="header-center-title">
{darkLogoPosition === "center" && (
<div ref={centerTitleRef} className="header-center-title">
{topNavLogoPosition === "center" && (
<AppBrandImage
theme={theme}
className="header-center-title__logo"
/>
)}
<span className="header-center-title__label">
{desensitizeText(systemName)}
<span
className="header-center-title__label"
data-lines={renderedCenterTitleFit.lines}
style={{
fontSize: `${renderedCenterTitleFit.fontSize}px`,
letterSpacing: `${renderedCenterTitleFit.letterSpacing}px`,
}}
>
{displaySystemName}
</span>
<span
ref={centerTitleMeasureRef}
className="header-center-title__measure"
aria-hidden="true"
>
{displaySystemName}
</span>
</div>
@@ -343,7 +502,7 @@ const Header = ({ collapsed }: HeaderProps) => {
/>
</div>
<span className={cn("whitespace-nowrap text-lg font-semibold", headerTextClass)}>
{desensitizeText(systemName)}
{displaySystemName}
</span>
</div>
<nav className="light-topnav" aria-label="一级菜单">
@@ -377,7 +536,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", headerTextClass)}>
{desensitizeText(systemName)}
{displaySystemName}
</span>
)}
</div>
@@ -389,7 +548,7 @@ const Header = ({ collapsed }: HeaderProps) => {
)}
</header>
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
{showTopNav && thirdSubNavItems.length ? (
{showThirdSubNav && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
{thirdSubNavItems.map((item) => {
@@ -28,6 +28,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
import {
Dialog,
DialogContent,
@@ -37,6 +38,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import { Pagination } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import {
Select,
@@ -48,7 +57,11 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { useOrdinaryKnowledgeRetrievalSettings } from "@/core/system-settings/hooks";
import { env } from "@/env";
import {
useOrdinaryKnowledgeRetrievalSettings,
usePageDescriptionsSettings,
} from "@/core/system-settings/hooks";
import {
formatWikiDirectoryPaths,
type WikiDirectoryTreeNode,
@@ -87,9 +100,17 @@ import {
} from "@/strategy-components/api/llmwiki";
type WorkspaceTab = "personal" | "public" | "settings";
type KnowledgeSort = "name-asc" | "name-desc" | "created-desc" | "created-asc";
const LIST_PATH = "/page/workspace/knowledge";
const WIKI_INSTRUCTION_MAX_LENGTH = 4000;
const WIKI_FOCUS_INSTRUCTION_MAX_LENGTH = 2000;
const knowledgePageSizeOptions = [10, 20, 50] as const;
type KnowledgePageSize = (typeof knowledgePageSizeOptions)[number];
const knowledgeNameCollator = new Intl.Collator("zh-Hans-CN", {
numeric: true,
sensitivity: "base",
});
const publicationLabels: Record<string, string> = {
private: "仅自己",
@@ -106,6 +127,34 @@ function codePointLength(value: string): number {
return Array.from(value).length;
}
function knowledgeNameInitial(value: string): string | null {
const match = value.trim().match(/^([a-z])\d+-/i);
return match ? match[1].toUpperCase() : null;
}
function knowledgeCreatedAtTimestamp(item: LlmWikiKnowledgeBase): number {
const value = item.created_at || item.remote_created_at;
const timestamp = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(timestamp) ? timestamp : 0;
}
function compareKnowledgeBases(
left: LlmWikiKnowledgeBase,
right: LlmWikiKnowledgeBase,
sort: KnowledgeSort,
): number {
const nameOrder = knowledgeNameCollator.compare(left.name, right.name);
const createdOrder = knowledgeCreatedAtTimestamp(left) - knowledgeCreatedAtTimestamp(right);
const order = sort === "name-asc"
? nameOrder
: sort === "name-desc"
? -nameOrder
: sort === "created-asc"
? createdOrder
: -createdOrder;
return order || nameOrder || left.id.localeCompare(right.id);
}
function isWikiKnowledgeBase(knowledgeBase: LlmWikiKnowledgeBase): boolean {
if (typeof knowledgeBase.wiki_generation_managed === "boolean") {
return knowledgeBase.wiki_generation_managed;
@@ -163,11 +212,20 @@ function resultPreview(result: LlmWikiSearchResult) {
export function WeKnoraKnowledgeWorkspace() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const pageDescriptions = usePageDescriptionsSettings();
const pageDescription = pageDescriptions.data?.knowledge_management;
const knowledgeManagementSettings =
useOrdinaryKnowledgeRetrievalSettings(isAdmin);
const advancedManagementEnabled =
knowledgeManagementSettings.data?.advanced_management_enabled === true;
const [tab, setTab] = useState<WorkspaceTab>("personal");
const [viewMode, setViewMode] = useState<"card" | "list">("list");
const [sort, setSort] = useState<KnowledgeSort>("name-asc");
const [nameInitialFilter, setNameInitialFilter] = useState<string | null>(null);
const [knowledgePage, setKnowledgePage] = useState(1);
const [knowledgePageSize, setKnowledgePageSize] = useState<KnowledgePageSize>(20);
const viewSwitchEnabled = env.KNOWLEDGE_VIEW_SWITCH_ENABLED;
const effectiveViewMode = viewSwitchEnabled ? viewMode : "list";
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
const actions = useLlmWikiActions();
@@ -177,12 +235,28 @@ export function WeKnoraKnowledgeWorkspace() {
const [searching, setSearching] = useState(false);
const [searchResponse, setSearchResponse] = useState<LlmWikiSearchResponse | null>(null);
const [selectedSearchResult, setSelectedSearchResult] = useState<LlmWikiSearchResult | null>(null);
const availableNameInitials = useMemo(() => {
if (tab !== "personal") return [];
return [...new Set(
(knowledge.data ?? [])
.filter((item) => item.is_owner && item.remote_status !== "missing")
.map((item) => knowledgeNameInitial(item.name))
.filter((initial): initial is string => initial !== null),
)].sort(knowledgeNameCollator.compare);
}, [knowledge.data, tab]);
useEffect(() => {
setSearchResponse(null);
setSelectedSearchResult(null);
setKnowledgePage(1);
}, [tab]);
useEffect(() => {
if (nameInitialFilter && !availableNameInitials.includes(nameInitialFilter)) {
setNameInitialFilter(null);
}
}, [availableNameInitials, nameInitialFilter]);
const busy = actions.create.isPending || actions.update.isPending || actions.remove.isPending || actions.publication.isPending;
const setCreateDialogOpen = (open: boolean) => {
if (open) actions.create.reset();
@@ -195,6 +269,10 @@ export function WeKnoraKnowledgeWorkspace() {
const openDetails = (item: LlmWikiKnowledgeBase) => {
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
};
const changeKnowledgePageSize = (pageSize: KnowledgePageSize) => {
setKnowledgePageSize(pageSize);
setKnowledgePage(1);
};
const runSearch = async () => {
const query = searchText.trim();
@@ -236,10 +314,14 @@ export function WeKnoraKnowledgeWorkspace() {
<div>
<div className="flex items-center gap-2">
<Database className="size-5 text-primary" />
<h1 className="text-xl font-semibold">知识库管理</h1>
<h1 className="text-xl font-semibold">
{pageDescription?.title || "知识库管理"}
</h1>
<Badge variant="secondary">知识服务</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
<p className="mt-1 text-sm text-muted-foreground">
{pageDescription?.description || "由知识服务负责解析与检索,用户、权限和发布范围由 cmzs 管理。"}
</p>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
{tab !== "settings" && (
@@ -252,7 +334,9 @@ export function WeKnoraKnowledgeWorkspace() {
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
</Button>
)}
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
{tab === "personal" && <>
<Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateDialogOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>
</>}
</div>
</div>
</div>
@@ -267,18 +351,57 @@ export function WeKnoraKnowledgeWorkspace() {
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
</TabsList>
{tab !== "settings" && (
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
<div className="flex w-full items-center gap-2 sm:w-auto">
<Select
value={sort}
onValueChange={(value) => {
setSort(value as KnowledgeSort);
setKnowledgePage(1);
}}
>
<SelectTrigger className="w-36 shrink-0" aria-label="知识库排序">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="name-asc">名称:A–Z</SelectItem>
<SelectItem value="name-desc">名称:Z–A</SelectItem>
<SelectItem value="created-desc">创建时间:最新</SelectItem>
<SelectItem value="created-asc">创建时间:最早</SelectItem>
</SelectContent>
</Select>
{tab === "personal" && availableNameInitials.length > 0 && (
<Select
value={nameInitialFilter ?? "all"}
onValueChange={(value) => {
setNameInitialFilter(value === "all" ? null : value);
setKnowledgePage(1);
}}
>
<SelectTrigger className="w-28 shrink-0" aria-label="按名称首字母筛选">
<SelectValue placeholder="首字母" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">全部字母</SelectItem>
{availableNameInitials.map((initial) => (
<SelectItem key={initial} value={initial}>{initial}</SelectItem>
))}
</SelectContent>
</Select>
)}
{viewSwitchEnabled && <ViewModeToggle value={viewMode} onChange={setViewMode} className="shrink-0" />}
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
<div className="relative min-w-0 flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共 Wiki 文档" : "检索我的 Wiki 文档"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
</div>
<Button variant="secondary" className="shrink-0 rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
</div>
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索 Wiki"}</Button>
</div>
)}
</div>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="personal" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={nameInitialFilter} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
<TabsContent value="public" className="mt-5"><KnowledgeGrid viewMode={effectiveViewMode} sort={sort} nameInitialFilter={null} page={knowledgePage} pageSize={knowledgePageSize} onPageChange={setKnowledgePage} onPageSizeChange={changeKnowledgePageSize} rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onResearch={(item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/research`)} onTest={isAdmin && advancedManagementEnabled ? (item) => navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}/retrieval-debug`) : undefined} onEdit={openKnowledgeEditor} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
</Tabs>
@@ -472,6 +595,13 @@ function WikiConfigurationStatus({
}
function KnowledgeGrid({
viewMode,
sort,
nameInitialFilter,
page,
pageSize,
onPageChange,
onPageSizeChange,
rows,
loading,
error,
@@ -484,6 +614,13 @@ function KnowledgeGrid({
onPublish,
onDelete,
}: {
viewMode: "card" | "list";
sort: KnowledgeSort;
nameInitialFilter: string | null;
page: number;
pageSize: KnowledgePageSize;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: KnowledgePageSize) => void;
rows: LlmWikiKnowledgeBase[];
loading: boolean;
error: unknown;
@@ -498,11 +635,92 @@ function KnowledgeGrid({
}) {
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
const visibleRows = rows.filter((item) => item.remote_status !== "missing");
if (!visibleRows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
const visibleRows = rows.filter(
(item) => item.remote_status !== "missing" &&
(!nameInitialFilter || knowledgeNameInitial(item.name) === nameInitialFilter),
);
if (!visibleRows.length) {
return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{nameInitialFilter ? `没有名称以 ${nameInitialFilter} 开头的知识库` : emptyText}</div>;
}
const sortedRows = [...visibleRows].sort((left, right) => compareKnowledgeBases(left, right, sort));
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
const currentPage = Math.min(Math.max(page, 1), totalPages);
const pageRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
const pagination = (
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
<span>共 {sortedRows.length} 个知识库 · 第 {currentPage}/{totalPages} 页</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value) as KnowledgePageSize)}
>
<SelectTrigger className="h-8 w-28" aria-label="每页知识库数量">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{knowledgePageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>{option} 条/页</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination
page={currentPage}
pageSize={pageSize}
total={sortedRows.length}
onPageChange={onPageChange}
/>
</div>
);
if (viewMode === "list") {
return (
<div>
<div className="flex flex-col gap-3">
{pageRows.map((item) => (
<Item key={item.id} variant="outline" size="sm" className="w-full">
<ItemContent
className="min-w-0 cursor-pointer"
role="button"
tabIndex={0}
aria-label={`查看知识库 ${item.name}`}
onClick={() => onOpen(item)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(item);
}
}}
>
<ItemTitle className="max-w-full flex-wrap">
<span className="truncate">{item.name}</span>
<Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>
{isWikiKnowledgeBase(item) && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}
<WikiConfigurationStatus knowledgeBase={item} />
</ItemTitle>
<ItemDescription className="line-clamp-1">{item.description || "暂无描述"}</ItemDescription>
</ItemContent>
<ItemActions className="flex-wrap justify-end">
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>
<Button size="sm" variant="outline" className="rounded-full" onClick={() => onResearch(item)}><Microscope className="mr-2 size-4" />深度研究</Button>
{onTest && <Button size="sm" variant="outline" className="rounded-full" onClick={() => onTest(item)}><Search className="mr-2 size-4" />检索测试</Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="sm" variant="outline" className="rounded-full" disabled={busy} onClick={() => onEdit(item)} aria-label={`编辑知识库 ${item.name}`}><Edit3 className="mr-2 size-4" />编辑</Button>}
{item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={`删除知识库 ${item.name}`} title="删除知识库" disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
</ItemActions>
</Item>
))}
</div>
{pagination}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visibleRows.map((item) => (
<div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{pageRows.map((item) => (
<Card
key={item.id}
className="group overflow-hidden rounded-2xl border-border/70 bg-card/95 transition-all hover:-translate-y-0.5 hover:border-primary/30 hover:shadow-lg"
@@ -531,6 +749,8 @@ function KnowledgeGrid({
</CardContent>
</Card>
))}
</div>
{pagination}
</div>
);
}
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
calculateHeaderTitleFit,
resolveHeaderTitleFit,
} from "./header-title-fit";
test("keeps the existing 30px title treatment when the name fits", () => {
assert.deepEqual(
calculateHeaderTitleFit({ availableWidth: 500, naturalWidth: 420 }),
{ fontSize: 30, letterSpacing: 4, lines: 1 },
);
});
test("shrinks a long name proportionally while keeping it on one line", () => {
const fit = calculateHeaderTitleFit({
availableWidth: 360,
naturalWidth: 420,
characterCount: 12,
});
assert.equal(fit.lines, 1);
assert.ok(fit.fontSize < 30);
assert.ok(fit.fontSize > 24);
assert.equal(fit.letterSpacing, 0);
});
test("tightens character spacing before reducing the current maximum font size", () => {
assert.deepEqual(
calculateHeaderTitleFit({
availableWidth: 390,
naturalWidth: 420,
characterCount: 12,
}),
{ fontSize: 30, letterSpacing: 1.41, lines: 1 },
);
});
test("keeps one line at the readable floor and allows intentional overflow", () => {
assert.deepEqual(
calculateHeaderTitleFit({
availableWidth: 300,
naturalWidth: 900,
characterCount: 30,
}),
{ fontSize: 24, letterSpacing: 0, lines: 1 },
);
});
test("does not wrap or shrink below 24px for extreme names", () => {
const fit = calculateHeaderTitleFit({
availableWidth: 300,
naturalWidth: 2400,
characterCount: 80,
});
assert.deepEqual(fit, { fontSize: 24, letterSpacing: 0, lines: 1 });
});
test("uses the configured font size without adaptive shrinking in fixed mode", () => {
assert.deepEqual(
resolveHeaderTitleFit({
mode: "fixed",
fixedFontSize: 34,
adaptiveFit: { fontSize: 24, letterSpacing: 0, lines: 1 },
}),
{ fontSize: 34, letterSpacing: 4, lines: 1 },
);
});
test("bounds fixed font sizes to the supported navigation range", () => {
const adaptiveFit = { fontSize: 28, letterSpacing: 1, lines: 1 };
assert.equal(
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 8, adaptiveFit })
.fontSize,
16,
);
assert.equal(
resolveHeaderTitleFit({ mode: "fixed", fixedFontSize: 60, adaptiveFit })
.fontSize,
40,
);
assert.strictEqual(
resolveHeaderTitleFit({
mode: "adaptive",
fixedFontSize: 40,
adaptiveFit,
}),
adaptiveFit,
);
});
@@ -0,0 +1,149 @@
export const HEADER_TITLE_MAX_FONT_SIZE = 30;
export const HEADER_TITLE_MIN_FONT_SIZE = 24;
export const HEADER_TITLE_MAX_LETTER_SPACING = 4;
export const HEADER_TITLE_MAX_LINES = 1;
export const HEADER_TITLE_FIXED_MIN_FONT_SIZE = 16;
export const HEADER_TITLE_FIXED_MAX_FONT_SIZE = 40;
export type HeaderTitleFit = {
fontSize: number;
letterSpacing: number;
lines: number;
};
type ResolveHeaderTitleFitOptions = {
mode: "adaptive" | "fixed";
fixedFontSize: number;
adaptiveFit: HeaderTitleFit;
};
type CalculateHeaderTitleFitOptions = {
availableWidth: number;
naturalWidth: number;
characterCount?: number;
maxFontSize?: number;
minFontSize?: number;
maxLetterSpacing?: number;
maxLines?: number;
};
const roundDown = (value: number) => Math.floor(value * 100) / 100;
export function resolveHeaderTitleFit({
mode,
fixedFontSize,
adaptiveFit,
}: ResolveHeaderTitleFitOptions): HeaderTitleFit {
if (mode !== "fixed") {
return adaptiveFit;
}
const normalizedFontSize = Number.isFinite(fixedFontSize)
? Math.min(
HEADER_TITLE_FIXED_MAX_FONT_SIZE,
Math.max(HEADER_TITLE_FIXED_MIN_FONT_SIZE, fixedFontSize),
)
: HEADER_TITLE_MAX_FONT_SIZE;
return {
fontSize: roundDown(normalizedFontSize),
letterSpacing: HEADER_TITLE_MAX_LETTER_SPACING,
lines: 1,
};
}
/**
* Fits the full system name into the decorative title slot without ellipsis.
* The current 30px treatment remains the upper bound. The title stays on one
* line and stops shrinking at 24px; overflow is intentional beyond that point.
*/
export function calculateHeaderTitleFit({
availableWidth,
naturalWidth,
characterCount = 1,
maxFontSize = HEADER_TITLE_MAX_FONT_SIZE,
minFontSize = HEADER_TITLE_MIN_FONT_SIZE,
maxLetterSpacing = HEADER_TITLE_MAX_LETTER_SPACING,
maxLines = HEADER_TITLE_MAX_LINES,
}: CalculateHeaderTitleFitOptions): HeaderTitleFit {
if (
!Number.isFinite(availableWidth) ||
!Number.isFinite(naturalWidth) ||
availableWidth <= 0 ||
naturalWidth <= 0 ||
maxFontSize <= 0
) {
return {
fontSize: maxFontSize,
letterSpacing: maxLetterSpacing,
lines: 1,
};
}
// Keep a one-pixel safety margin for browser sub-pixel text measurement.
const safeAvailableWidth = Math.max(1, availableWidth - 1);
const safeCharacterCount = Math.max(1, characterCount);
const maximumSpacingWidth = maxLetterSpacing * safeCharacterCount;
const naturalGlyphWidth = Math.max(1, naturalWidth - maximumSpacingWidth);
const minimumScale = Math.min(1, Math.max(0.01, minFontSize / maxFontSize));
if (naturalWidth <= safeAvailableWidth) {
return {
fontSize: maxFontSize,
letterSpacing: maxLetterSpacing,
lines: 1,
};
}
// Preserve the existing maximum font size as long as possible by tightening
// only the decorative character spacing first.
if (naturalGlyphWidth <= safeAvailableWidth) {
return {
fontSize: maxFontSize,
letterSpacing: roundDown(
Math.max(
0,
Math.min(
maxLetterSpacing,
(safeAvailableWidth - naturalGlyphWidth) / safeCharacterCount,
),
),
),
lines: 1,
};
}
const singleLineScale = safeAvailableWidth / naturalGlyphWidth;
if (singleLineScale >= minimumScale) {
return {
fontSize: roundDown(maxFontSize * singleLineScale),
letterSpacing: 0,
lines: 1,
};
}
if (maxLines <= 1) {
return {
fontSize: minFontSize,
letterSpacing: 0,
lines: 1,
};
}
const requiredLines = Math.ceil(
(naturalGlyphWidth * minimumScale) / safeAvailableWidth,
);
const lines = Math.min(Math.max(2, requiredLines), Math.max(1, maxLines));
// Hold the readable minimum where possible. If even maxLines cannot contain
// the name, shrink just enough to preserve every character.
const scale = Math.min(
minimumScale,
(safeAvailableWidth * lines) / naturalGlyphWidth,
);
return {
fontSize: roundDown(maxFontSize * scale),
letterSpacing: 0,
lines,
};
}
@@ -40,6 +40,7 @@ import {
} from "@/core/artifacts/download";
import { getStoredAuth } from "@/core/auth";
import {
usePageDescriptionsSettings,
useStrategyProjectSettings,
useUpdateStrategyProjectSettings,
} from "@/core/system-settings/hooks";
@@ -321,6 +322,8 @@ export default function StrategyProjectsPage({
isError: demoSettingError,
} = useStrategyProjectSettings();
const updateStrategyProjectSettings = useUpdateStrategyProjectSettings();
const pageDescriptions = usePageDescriptionsSettings();
const projectsPageDescription = pageDescriptions.data?.strategy_projects;
const strategyAngleCatalog = useStrategyAngleCatalog();
const createAngles = strategyAngleCatalog.angles;
const demoEnabled =
@@ -472,10 +475,12 @@ export default function StrategyProjectsPage({
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<FolderKanban className="size-4" />
</span>
<h1 className="text-xl font-semibold tracking-tight">我的项目</h1>
<h1 className="text-xl font-semibold tracking-tight">
{projectsPageDescription?.title || "我的项目"}
</h1>
</div>
<p className="mt-1.5 truncate text-xs text-muted-foreground sm:text-sm">
集中查看普通问答、策略优选和方案成果
{projectsPageDescription?.description || "集中查看普通问答、策略优选和方案成果"}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
+63 -7
View File
@@ -1528,19 +1528,38 @@ html.dark .strategy-app-header {
background-size: cover;
}
/* 深色顶栏:居中「CM助手」标题,落在背景图中间的发光梯形基座上。
左右菜单分区各 flex-1 等宽,本元素居中即对齐背景图基座。 */
/* 红白主题可通过 runtime-config.js 选择复用深色顶栏结构,仅替换为浅红设计稿。 */
.strategy-app-header.strategy-app-header--light-red-top-nav {
background-image: var(
--light-red-top-nav-background-image,
url("../assets/img/top-bg-light-red.svg")
);
background-size: cover;
}
/* 深色顶栏:居中系统名称落在背景图中间的发光梯形基座上。
背景原稿为 1920px 宽,基座槽位约 445px;视口超过原稿宽度后随背景等比放大。
固定这个视觉槽位后,用户名称不再反向撑宽容器、挤动左右菜单。 */
.header-center-title {
flex: 0 0 auto;
--header-center-slot-width: max(445px, 23.18vw);
/* 凹槽内侧亮边在 1920px 原稿中约 352px 宽;两边各留约 9px,
字号测量按 334px 安全区随背景等比放大,但外层槽宽保持不变,
因此不会挪动左右导航,也不会改变 Logo 与文字的 10px 间距。 */
--header-center-title-safe-width: max(334px, 17.4vw);
width: var(--header-center-slot-width);
flex: 0 0 var(--header-center-slot-width);
box-sizing: border-box;
height: 64px;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
padding: 0 8.07vw;
padding: 0
calc(
(var(--header-center-slot-width) - var(--header-center-title-safe-width)) /
2
);
/* 文字略微上移,贴合基座视觉中心 */
transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
@@ -1565,7 +1584,12 @@ html.dark .strategy-app-header {
.header-center-title__label {
display: block;
max-width: none;
flex: 0 0 auto;
overflow: visible;
line-height: 1;
text-align: center;
white-space: nowrap;
/* 白→浅蓝→白 金属质感渐变文字 */
background: linear-gradient(
180deg,
@@ -1582,6 +1606,38 @@ html.dark .strategy-app-header {
filter: drop-shadow(0 0 14px rgba(120, 200, 255, 0.35));
}
.header-center-title__label[data-lines="2"],
.header-center-title__label[data-lines="3"] {
line-height: 1.05;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-all;
}
/* 始终以现有最大字号测量名称的自然宽度,不参与布局和无障碍树。 */
.header-center-title__measure {
position: absolute;
visibility: hidden;
white-space: nowrap;
font-size: 30px;
font-weight: 700;
letter-spacing: 4px;
line-height: 1;
pointer-events: none;
}
.strategy-app-header--light-red-top-nav .header-center-title__label {
background: linear-gradient(
180deg,
#ffffff 0%,
#fff8f7 42%,
#ffffff 100%
);
-webkit-background-clip: text;
background-clip: text;
filter: drop-shadow(0 0 12px rgba(161, 32, 24, 0.38));
}
/* ── 浅蓝主题:覆盖散落在各处的红色硬编码(CSS 变量管不到的地方)────────── */
html.lightblue .strategy-app-header {
background-image: url("../assets/img/blue-header.png");
+89 -12
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -8,11 +8,63 @@ import sourceIdentifierPlugin from "vite-plugin-source-identifier";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
const JSX_RUNTIME_NAMED_EXPORTS = [
"jsx",
"jsxs",
"jsxDEV",
"createElement",
"Fragment",
] as const;
const JSX_RUNTIME_DEP_FILES = new Set([
"react_jsx-runtime.js",
"react_jsx-dev-runtime.js",
]);
function hasNamedJsxExport(code: string): boolean {
return (
/\bexport\s+const\s+jsx\b/.test(code) ||
/\bexport\s+function\s+jsx\b/.test(code) ||
/\bexport\s*\{[^}]*\bjsx\b/.test(code)
);
}
// Vite prebundles CJS `react/jsx-runtime` as `export default require_jsx_runtime()`.
// App source is rewritten to a default import, but a stale Chrome 102 tab may still
// execute `import { jsx } from '.../react_jsx-runtime.js'`. Named re-exports from a
// default-only module throw SyntaxError at link time, so the page never reloads.
function withNamedJsxExports(code: string): string {
if (hasNamedJsxExport(code)) return code;
return code.replace(
/export\s+default\s+([^;]+);/,
(_match, expr: string) =>
[
`const __jsxRuntime = ${String(expr).trim()};`,
"export default __jsxRuntime;",
...JSX_RUNTIME_NAMED_EXPORTS.map(
(name) => `export const ${name} = __jsxRuntime.${name};`,
),
].join("\n"),
);
}
function staleDepsModuleSource(): string {
return [
"export function jsx(){return null}",
"export function jsxs(){return null}",
"export function jsxDEV(){return null}",
"export const Fragment = Symbol.for('react.fragment');",
"export default {jsx, jsxs, jsxDEV, Fragment};",
"try{const k='__vite_stale_chunk_reloaded__';const n=Number(sessionStorage.getItem(k)||'0');" +
"if(n<3){sessionStorage.setItem(k,String(n+1));location.reload();}}catch(e){}",
].join("\n");
}
// 依赖预构建缓存被重建后(pnpm install / 重复启动 vite / --force),
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk,
// 每次都在终端刷 "The file does not exist ... in the optimize deps directory"。
// 该插件拦截这类「文件确实不存在」的 deps 请求,返回一段触发 location.reload()
// 的模块脚本,让旧页面自愈到新版本;sessionStorage 防止真正的坏页面无限刷新。
// 残留的旧浏览器标签页仍会按旧版本号(?v=<hash>)请求已删除的 chunk。
// Chrome 在链接 `import { jsx } from ...` 时若没有本地 named export,会直接
// SyntaxError,模块体根本不会执行,页面就白屏卡死。缺失 chunk 一律补本地
// jsx 导出再刷新;真实 jsx-runtime 也补上 named export,兼容旧模块图。
const staleDepsChunkSelfReload = (): Plugin => ({
name: "stale-deps-chunk-self-reload",
configureServer(server) {
@@ -23,17 +75,28 @@ const staleDepsChunkSelfReload = (): Plugin => ({
next();
return;
}
const filePath = path.join(depsDir, decodeURIComponent(match[1]));
const fileName = path.basename(decodeURIComponent(match[1]));
if (fileName !== decodeURIComponent(match[1])) {
next();
return;
}
const filePath = path.join(depsDir, fileName);
if (existsSync(filePath)) {
if (!JSX_RUNTIME_DEP_FILES.has(fileName)) {
next();
return;
}
res.setHeader("Content-Type", "application/javascript");
res.end(withNamedJsxExports(readFileSync(filePath, "utf8")));
return;
}
if (!/^chunk-[A-Za-z0-9_-]+\.js$/.test(fileName)) {
next();
return;
}
res.setHeader("Content-Type", "application/javascript");
res.end(
"if(!sessionStorage.getItem('__vite_stale_chunk_reloaded__')){" +
"sessionStorage.setItem('__vite_stale_chunk_reloaded__','1');" +
"location.reload();}",
);
res.setHeader("Cache-Control", "no-store");
res.end(staleDepsModuleSource());
});
},
});
@@ -91,7 +154,21 @@ export default defineConfig({
// 下混用新旧缓存 chunk,报 "does not provide an export named 'refractor'"
// 并整树白屏(清 node_modules/.vite + 重启 + 硬刷新才恢复)。启动即预打包可避免。
optimizeDeps: {
include: ["react-syntax-highlighter", "refractor"],
include: [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
"react-syntax-highlighter",
"refractor",
],
esbuildOptions: {
target: "chrome100",
},
},
esbuild: {
target: "chrome100",
},
// Relative URLs make the same build work at the domain root and at any
// Nginx path prefix. A fixed prefix can still be supplied at release time.