diff --git a/.gitignore b/.gitignore index 8a198ffa..2c40c5a4 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,16 @@ src/octop/dashboard/assets/* !src/octop/dashboard/__init__.py !src/octop/dashboard/.gitkeep +# 美团生活助手专家:随包分发 pt-passport 运行时(Docker 镜像无 npm, +# 依赖此预装产物执行认证;包内文件由来源 tgz 固定,不会被误提交污染) +# 注意:git 不会进入被忽略的目录,因此必须先反排除 node_modules 目录本身 +!src/octop/infra/agents/experts/library/meituan-living-assistant/ +!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/ +!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/ +!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/ +!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/ +!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/** + # Local databases *.sqlite *.sqlite-journal diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f96ff10..9455df30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ ## [Unreleased] +### 新增 + +- 智能体状态接口返回 `memory_maintenance`(queued / pruning / compacting)。聊天页显示阶段进度条,整理本库时暂停发送;专家卡片显示「整理记忆」标签。 + +### 修复 + +- 插件工具使用中文等非 ASCII 名称时 LLM 调用失败:主流 API 要求工具名匹配 `^[a-zA-Z0-9_-]{1,64}$`,现自动将非法名称转写为合法拼音名(`pypinyin` 缺失时退回下划线替换),冲突追加 `_2`/`_3` 后缀,并在工具描述前缀 `[原名: …]` 保留原名映射;`config_json.plugins` 配置键与插件内部仍使用原始名称,路由不受影响 +- 修复聊天页在"生成中"时于输入框持续打字导致消息列表上下轻微抖动的问题:输入框高度测量改为在离屏克隆节点上进行,不再瞬态改变页面布局 + ## [0.9.24] - 2026-08-15 ### 新增 diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 76614d63..71f16824 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -15,7 +15,7 @@ import { AgentProvider } from "./context/AgentContext"; import { VoiceOutputProvider } from "./context/VoiceOutputContext"; import { useIsMobile } from "./hooks/useIsMobile"; import { useUnauthorizedRedirect } from "./hooks/useUnauthorizedRedirect"; -import { ANTD_BRAND_TOKENS } from "./styles/themePalettes"; +import { brandTokensFor } from "./styles/themePalettes"; import "./styles/theme-vars.css"; import "./styles/layout.css"; import "./styles/form-override.css"; @@ -28,10 +28,10 @@ const GlobalStyle = createGlobalStyle` `; function ThemedApp() { - const { isDark, palette } = useTheme(); + const { isDark, palette, customColor } = useTheme(); const { t } = useTranslation(); const isMobile = useIsMobile(); - const brandTokens = ANTD_BRAND_TOKENS[palette][isDark ? "dark" : "light"]; + const brandTokens = brandTokensFor(palette, isDark, customColor); useUnauthorizedRedirect(); diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index ddf4d6fe..03433bb0 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -118,6 +118,17 @@ export const knowledgeBasesApi = { "/knowledge-bases/onnx-download-status", ), + testOnnx: (model: string) => + request<{ + ok: boolean; + latency_ms?: number | null; + dim?: number | null; + error?: string | null; + }>("/knowledge-bases/onnx-test", { + method: "POST", + body: JSON.stringify({ model }), + }), + activateOnnx: (model: string) => request<{ enabled: boolean; model: string; ready: boolean }>( "/knowledge-bases/onnx-activate", diff --git a/dashboard/src/api/modules/memoryDashboard.ts b/dashboard/src/api/modules/memoryDashboard.ts index dd604362..eba79d8c 100644 --- a/dashboard/src/api/modules/memoryDashboard.ts +++ b/dashboard/src/api/modules/memoryDashboard.ts @@ -237,6 +237,16 @@ export interface RejectCandidateResponse { status: "rejected"; } +export interface LastExtractRun { + timestamp?: string; + session_id?: string | null; + quiet?: boolean; + note?: string; + events_extracted?: number; + candidates?: number; + failure_reason?: string | null; +} + export interface StatsCounts { raw_events: number; atoms: number; @@ -247,6 +257,7 @@ export interface StatsCounts { atoms_delta_7d: number; entities_delta_7d: number; episodes_delta_7d: number; + last_extract_run?: LastExtractRun | null; } export interface StatsAtomKindsResponse { diff --git a/dashboard/src/api/modules/voice.ts b/dashboard/src/api/modules/voice.ts index fd401ad1..741c0e3c 100644 --- a/dashboard/src/api/modules/voice.ts +++ b/dashboard/src/api/modules/voice.ts @@ -1,4 +1,4 @@ -import { request, requestBlob, requestUpload } from "../request"; +import { request, requestBlob, requestStream, requestUpload } from "../request"; export interface VoicePreset { id: string; @@ -66,6 +66,23 @@ export const voiceApi = { ...(provider ? { provider } : {}), }), }), + /** + * Streamed variant for low-latency playback. The server flushes chunks as + * they arrive (MiMo streams live WAV); other providers stream MP3, in + * which case the buffered `synthesize` path is used instead. + */ + synthesizeStream: ( + text: string, + provider?: string, + ): Promise<{ contentType: string; body: ReadableStream }> => + requestStream("/voice/tts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + ...(provider ? { provider } : {}), + }), + }), createProvider: (body: { name: string; kind: string; diff --git a/dashboard/src/api/request.ts b/dashboard/src/api/request.ts index 12697285..c541c431 100644 --- a/dashboard/src/api/request.ts +++ b/dashboard/src/api/request.ts @@ -357,6 +357,47 @@ export async function probeAuthResource( } } +/** + * POST JSON and hand back the response body as a byte stream (chunked TTS). + * Mirrors requestBlob()'s auth/setup/401 handling but never buffers. + */ +export async function requestStream( + path: string, + options: RequestInit = {}, +): Promise<{ contentType: string; body: ReadableStream }> { + const url = getApiUrl(path); + const headers = buildAuthHeaders(path); + const response = await fetch(url, { + ...options, + headers: { ...headers, ...(options.headers as Record) }, + }); + + if (await check503ForSetupRequired(path, response)) { + throw new Error("Setup required — redirecting to /setup"); + } + + await throwIfUnauthorized(path, response); + applyRenewedAccessToken(response); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Request failed: ${response.status} ${response.statusText}${ + text ? ` - ${text}` : "" + }`, + ); + } + + if (!response.body) { + throw new Error("Empty stream from server"); + } + + return { + contentType: response.headers.get("content-type") || "", + body: response.body, + }; +} + export type UploadProgressHandler = (percent: number) => void; /** diff --git a/dashboard/src/components/ExpertColorPicker.tsx b/dashboard/src/components/ExpertColorPicker.tsx index fbdcfbbc..bba2ca93 100644 --- a/dashboard/src/components/ExpertColorPicker.tsx +++ b/dashboard/src/components/ExpertColorPicker.tsx @@ -1,6 +1,8 @@ -import { Tooltip } from "antd"; +import { ColorPicker, Tooltip } from "antd"; import { useTranslation } from "react-i18next"; +import type { AggregationColor } from "antd/es/color-picker/color"; import { + DEFAULT_CUSTOM_COLOR, PALETTE_SWATCH, VALID_PALETTES, type ThemePalette, @@ -8,16 +10,26 @@ import { import styles from "./PaletteSwitcher.module.less"; interface ExpertColorPickerProps { - value: ThemePalette; - onChange: (palette: ThemePalette) => void; + /** Curated palette key, or an arbitrary hex string for a custom color. */ + value: string; + onChange: (value: string) => void; } -/** Curated 8-swatch picker for expert/agent accent color (list cards). */ +function isCurated(value: string): value is ThemePalette { + return (VALID_PALETTES as string[]).includes(value); +} + +/** + * Curated 8-swatch picker for expert/agent accent color, plus a custom + * color swatch backed by the Ant Design color picker (palette + hex input). + * The onChange callback receives a palette key or a hex string. + */ export default function ExpertColorPicker({ value, onChange, }: ExpertColorPickerProps) { const { t } = useTranslation(); + const curated = isCurated(value); return (
@@ -42,6 +54,31 @@ export default function ExpertColorPicker({ ); })} + + + { + onChange(color.toHexString()); + }} + disabledAlpha + > + + + + + {!curated && value !== DEFAULT_CUSTOM_COLOR && ( + {value.toUpperCase()} + )}
); } diff --git a/dashboard/src/components/PaletteSwitcher.module.less b/dashboard/src/components/PaletteSwitcher.module.less index 28eb5678..68b4b2ad 100644 --- a/dashboard/src/components/PaletteSwitcher.module.less +++ b/dashboard/src/components/PaletteSwitcher.module.less @@ -56,3 +56,32 @@ 0 0 0 2px var(--fn-bg-elevated), 0 0 0 4px var(--fn-text-primary); } + +/* Custom color swatch: a rainbow conic gradient hints "pick any color". + When the custom palette is active the gradient is dropped so the inline + background-color (the chosen hex) shows through. */ +.customSwatch { + background-image: conic-gradient( + #f59e0b, + #ef4444, + #ec4899, + #8b5cf6, + #3b82f6, + #06b6d4, + #10b981, + #f59e0b + ); +} + +.active .customSwatch { + background-image: none; +} + +/* Hex readout next to the custom swatch when a non-curated color is active. */ +.customHex { + font-size: 11px; + font-family: var(--fn-font-mono, monospace); + color: var(--fn-text-tertiary); + align-self: center; + user-select: text; +} diff --git a/dashboard/src/components/PaletteSwitcher.tsx b/dashboard/src/components/PaletteSwitcher.tsx index a5d62de2..53e65582 100644 --- a/dashboard/src/components/PaletteSwitcher.tsx +++ b/dashboard/src/components/PaletteSwitcher.tsx @@ -1,13 +1,21 @@ -import { Tooltip } from "antd"; +import { ColorPicker, Tooltip } from "antd"; import { useTranslation } from "react-i18next"; +import type { AggregationColor } from "antd/es/color-picker/color"; import { useTheme } from "../context/ThemeContext"; import { PALETTE_SWATCH, VALID_PALETTES } from "../styles/themePalettes"; import styles from "./PaletteSwitcher.module.less"; +/** + * Curated 8-swatch brand palette picker plus a custom color swatch. + * The custom swatch opens the Ant Design color picker (palette + hex input); + * picking a color switches the active brand palette to "custom". + */ export default function PaletteSwitcher() { - const { palette, setPalette } = useTheme(); + const { palette, setPalette, customColor, setCustomColor } = useTheme(); const { t } = useTranslation(); + const isCustom = palette === "custom"; + return (
); })} + + + { + setCustomColor(color.toHexString()); + }} + disabledAlpha + > + + + +
); } diff --git a/dashboard/src/context/ThemeContext.tsx b/dashboard/src/context/ThemeContext.tsx index 2e5b5743..e883064a 100644 --- a/dashboard/src/context/ThemeContext.tsx +++ b/dashboard/src/context/ThemeContext.tsx @@ -11,7 +11,12 @@ import { writeStoredAppearance, type ThemePreference, } from "../styles/appearanceStorage"; -import { DEFAULT_PALETTE, type ThemePalette } from "../styles/themePalettes"; +import { + DEFAULT_PALETTE, + customPaletteCssVars, + normalizeHexColor, + type ThemePalette, +} from "../styles/themePalettes"; export type { ThemePreference }; @@ -30,6 +35,10 @@ interface ThemeContextValue { palette: ThemePalette; /** Set brand palette */ setPalette: (p: ThemePalette) => void; + /** Brand hex for the "custom" palette */ + customColor: string; + /** Set the custom brand hex (also switches palette to "custom") */ + setCustomColor: (hex: string) => void; /** Legacy toggle kept for backward compat (cycles light/dark) */ toggle: () => void; /** Whether the current mode is considered "dark" for Ant Design */ @@ -42,6 +51,8 @@ const ThemeContext = createContext({ setPreference: () => {}, palette: DEFAULT_PALETTE, setPalette: () => {}, + customColor: "", + setCustomColor: () => {}, toggle: () => {}, isDark: false, }); @@ -71,6 +82,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return loadAppearanceOnBoot().palette; }); + const [customColor, setCustomColorState] = useState( + () => loadAppearanceOnBoot().customColor ?? "", + ); + const [mode, setMode] = useState(() => resolveMode(preference)); // Listen for system color scheme changes when preference is "system" @@ -92,9 +107,30 @@ export function ThemeProvider({ children }: { children: ReactNode }) { // Persist preference + palette together (mode is derived, not stored) useEffect(() => { - writeStoredAppearance({ preference, palette }); + writeStoredAppearance({ preference, palette, customColor }); document.documentElement.setAttribute("data-palette", palette); - }, [preference, palette]); + }, [preference, palette, customColor]); + + // Inject the runtime-derived CSS variables for the custom palette. The + // style element is reused across renders; content updates on change. + useEffect(() => { + if (palette !== "custom") return; + const el = + document.getElementById("octop-custom-palette") ?? + (() => { + const node = document.createElement("style"); + node.id = "octop-custom-palette"; + document.head.appendChild(node); + return node; + })(); + const normalized = normalizeHexColor(customColor); + if (normalized) { + el.textContent = `${customPaletteCssVars( + normalized, + mode === "dark", + )}\n${customPaletteCssVars(normalized, mode !== "dark")}`; + } + }, [palette, customColor, mode]); useEffect(() => { document.documentElement.setAttribute("data-theme", mode); @@ -117,6 +153,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) { setPaletteState(p); }, []); + const setCustomColor = useCallback((hex: string) => { + const normalized = normalizeHexColor(hex); + if (normalized) { + setCustomColorState(normalized); + setPaletteState("custom"); + } + }, []); + const toggle = useCallback(() => { setPreferenceState((prev) => { if (prev === "light") return "dark"; @@ -133,6 +177,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { setPreference, palette, setPalette, + customColor, + setCustomColor, toggle, isDark: isDarkMode(mode), }} diff --git a/dashboard/src/hooks/useVoiceOutput.ts b/dashboard/src/hooks/useVoiceOutput.ts index 84e95a27..f7767c8f 100644 --- a/dashboard/src/hooks/useVoiceOutput.ts +++ b/dashboard/src/hooks/useVoiceOutput.ts @@ -10,6 +10,7 @@ import { import { prepareSpeechText } from "../utils/plainTextForSpeech"; import { speakBrowserText, stopBrowserSpeech } from "../utils/browserSpeech"; import { isMobileUserAgent } from "../utils/mobileDevice"; +import { WavStreamPlayer } from "../utils/wavStreamPlayer"; import { message as antMessage } from "@/utils/antdMessage"; @@ -17,6 +18,7 @@ export function useVoiceOutput() { const { t } = useTranslation(); const [speakingId, setSpeakingId] = useState(null); const audioRef = useRef(null); + const streamPlayerRef = useRef(null); const speakingIdRef = useRef(null); const playGenerationRef = useRef(0); @@ -28,6 +30,8 @@ export function useVoiceOutput() { const abortPlayback = useCallback(() => { playGenerationRef.current += 1; stopBrowserSpeech(); + streamPlayerRef.current?.stop(); + streamPlayerRef.current = null; if (audioRef.current) { audioRef.current.pause(); audioRef.current.onended = null; @@ -46,8 +50,81 @@ export function useVoiceOutput() { primeAudioElement(audio); }, []); + /** + * Stream the MiMo WAV response and schedule chunks as they arrive. + * Returns false when streaming is unsupported or the response is not a + * WAV — the caller then falls back to the buffered blob path. + */ + const speakMimoStream = useCallback( + async (plain: string, gen: number) => { + const player = new WavStreamPlayer(); + streamPlayerRef.current = player; + try { + if (!player.ensureContext()) return false; + const { contentType, body } = await voiceApi.synthesizeStream(plain); + if ( + !contentType.includes("audio/wav") && + !contentType.includes("audio/wave") + ) { + try { + await body.cancel(); + } catch { + /* ignore */ + } + return false; + } + const reader = body.getReader(); + // Read until the WAV header plus first audio chunk are scheduled — + // malformed streams can still fall back to the blob path here. + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + if (!player.push(value)) { + player.stop(); + return false; + } + if (player.hasAudio) break; + } + // Feed remaining chunks in the background until the stream ends. + void (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) player.push(value); + } + } catch { + /* network hiccup — keep whatever was scheduled */ + } finally { + const wait = Math.max(player.msRemaining(), 0); + window.setTimeout(() => { + player.stop(); + if (playGenerationRef.current === gen) finishSpeaking(); + }, wait); + } + })(); + if (playGenerationRef.current !== gen) { + player.stop(); + } + return true; + } catch { + return false; + } + }, + [finishSpeaking], + ); + const speakWithServer = useCallback( async (plain: string, gen: number, provider?: string) => { + // MiMo streams a live WAV — play it chunk-by-chunk for low latency. + // Other providers stream MP3, which needs the buffered blob path. + const active = cachedActiveVoice(); + const ttsProvider = provider ?? active?.tts; + if (ttsProvider === "mimo-tts" || ttsProvider === "mimo") { + if (await speakMimoStream(plain, gen)) return; + } + try { const blob = await voiceApi.synthesize(plain, provider); if (playGenerationRef.current !== gen) return; @@ -86,7 +163,7 @@ export function useVoiceOutput() { finishSpeaking(); } }, - [finishSpeaking, t], + [finishSpeaking, speakMimoStream, t], ); const speakWithBrowser = useCallback( diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index be84f34d..35d58ca7 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -385,6 +385,16 @@ "rebuildConfirmTitle": "Rebuild all knowledge indexes?", "rebuildConfirmDescription": "Changing the embedding model clears and re-embeds every knowledge index.", "notDownloaded": "not downloaded", + "checkRuntime": "Runtime", + "checkInstalled": "installed", + "checkRuntimeMissing": "not installed; enabling installs it", + "checkWeights": "Weights", + "checkDownloaded": "downloaded", + "checkEncode": "Encoding", + "probeIdle": "not checked yet", + "probeRun": "Check", + "probeOk": "works · {{dim}} dims · {{ms}} ms", + "probeFailed": "check failed", "approxSize": "about {{size}}", "sizeUnknown": "size unknown", "recommended": "Recommended", @@ -696,6 +706,7 @@ "agentDescription": "Description", "color": "Color", "colorHint": "Choose the accent color for list cards and chat avatars.", + "customColor": "Custom color", "defaultModel": "Default Model", "defaultModelAuto": "Auto (use provider default)", "editFile": "Edit →", @@ -703,6 +714,7 @@ "fileSaved": "{{filename}} saved", "fileSaveFailed": "Failed to save {{filename}}", "createDrawerTitle": "Create from \"{{name}}\"", + "memorySlimming": "Slimming memory", "agentStarted": "Agent \"{{name}}\" started", "agentStopped": "Agent \"{{name}}\" stopped", "agentStartFailed": "Failed to start agent", @@ -722,6 +734,23 @@ "skillPackagesLabel": "Skill Packages", "skillPackagesHint": "Mount selected packages when the agent is created. Currently supports only “Full local environment (filesystem + shell)” or “Local filesystem (no shell commands)”, and the storage root must be /.", "skillPackagesPlaceholder": "Select skill packages", + "manifestWriteFailed": "Page configuration could not be saved (the expert is reloading). It will be retried on the next save.", + "pageConfigTitle": "Page Configuration", + "welcomeMessageTitle": "Title", + "welcomeMessagePlaceholder": "Enter title shown for new chats", + "quickPromptsTitle": "Quick Start Cards", + "addQuickPrompt": "Add Card", + "quickPromptTitle": "Title", + "quickPromptTitlePlaceholder": "Enter card title", + "quickPromptDescription": "Description", + "quickPromptDescriptionPlaceholder": "Enter card description", + "quickPromptContent": "Prompt Content", + "quickPromptContentPlaceholder": "Enter prompt sent when card is clicked", + "quickPromptColor": "Color", + "quickPromptIcon": "Icon", + "quickPromptPreview": "Preview", + "noQuickPrompts": "No quick start cards yet, click the button above to add", + "noIcon": "No Icon", "iconLabels": { "sparkles": "Sparkles", "globe": "Globe", @@ -923,6 +952,14 @@ "sharedExpert": { "banner": "Shared expert · provided by {{name}}" }, + "memoryMaintenance": { + "queued": "Waiting to slim the memory database…", + "pruning": "Cleaning expired memory…", + "compacting": "Compacting the memory database…", + "hintQueued": "Another agent is compacting disk. This one can still chat; sending pauses when its turn starts.", + "hintBlocking": "Sending is paused for this agent until slimming finishes.", + "elapsed": "{{seconds}}s elapsed" + }, "thinking": "Thinking", "continuing": "Continuing", "generating": "Generating", @@ -1298,7 +1335,7 @@ "skillContent": "Skill Content", "pleaseInputName": "Please input skill name", "pleaseInputContent": "Please input skill content", - "skillNamePlaceholder": "e.g., weather_query", + "skillNamePlaceholder": "e.g., weather-analysis / 天气查询", "contentPlaceholder": "---\nname: (required)\ndescription: (required)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\nSkill implementation content...\n\n# Example:\n# ---\n# name: cron\n# description: Manage cron jobs via octop commands - create, query, pause, resume, delete tasks\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---", "createSuccess": "Skill created successfully", "importSuccess": "Skill imported successfully", @@ -1365,7 +1402,7 @@ "disableBeforeDelete": "Disable the skill before deleting it", "applyNow": "Apply now", "nameLabel": "Name", - "namePattern": "Only letters / digits / . _ - are allowed", + "namePattern": "Unicode names (e.g. Chinese) are allowed, but not / \\ : * ? \" < > |, a leading dot, or over 64 characters", "sourceLabel": "Source", "pathLabel": "Path", "metadataLabel": "Metadata", @@ -2662,7 +2699,8 @@ "violet": "Violet", "emerald": "Emerald", "amber": "Amber", - "slate": "Slate" + "slate": "Slate", + "custom": "Custom color" }, "currentVersion": "Current version v{{version}}", "currentVersionAdmin": "Current version v{{version}}. Click to open Updates.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 97ea00e9..db5fe5dd 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -385,6 +385,16 @@ "rebuildConfirmTitle": "重建知识库索引?", "rebuildConfirmDescription": "切换向量模型会清空并重新嵌入所有知识库索引。", "notDownloaded": "未下载", + "checkRuntime": "运行时组件", + "checkInstalled": "已安装", + "checkRuntimeMissing": "未安装,启用时会自动安装", + "checkWeights": "模型权重", + "checkDownloaded": "已下载", + "checkEncode": "实际编码", + "probeIdle": "尚未检测", + "probeRun": "检测", + "probeOk": "可用 · {{dim}} 维 · {{ms}} ms", + "probeFailed": "检测失败", "approxSize": "约 {{size}}", "sizeUnknown": "大小未知", "recommended": "推荐", @@ -696,6 +706,7 @@ "agentDescription": "描述", "color": "配色", "colorHint": "选择专家卡片配色,用于列表与聊天头像等展示。", + "customColor": "自定义颜色", "defaultModel": "默认模型", "defaultModelAuto": "Auto(使用 Provider 默认模型)", "editFile": "编辑 →", @@ -703,6 +714,7 @@ "fileSaved": "{{filename}} 已保存", "fileSaveFailed": "保存 {{filename}} 失败", "createDrawerTitle": "从「{{name}}」新建", + "memorySlimming": "整理记忆", "agentStarted": "专家「{{name}}」已启动", "agentStopped": "专家「{{name}}」已停止", "agentStartFailed": "启动失败", @@ -722,6 +734,24 @@ "skillPackagesLabel": "技能包", "skillPackagesHint": "创建专家时挂载选中的技能包。目前仅支持「本地完整环境(文件系统+指令执行)」或「本地文件系统(不能执行指令)」,且存储根目录必须为 /。", "skillPackagesPlaceholder": "选择技能包", + "patchFailed": "保存失败", + "manifestWriteFailed": "页面配置未能保存(专家正在重新加载),将在下次保存时重试。", + "pageConfigTitle": "页面配置", + "welcomeMessageTitle": "标题语", + "welcomeMessagePlaceholder": "输入新建聊天时显示的标题语", + "quickPromptsTitle": "快速启动卡片", + "addQuickPrompt": "添加卡片", + "quickPromptTitle": "标题", + "quickPromptTitlePlaceholder": "输入卡片标题", + "quickPromptDescription": "描述", + "quickPromptDescriptionPlaceholder": "输入卡片描述", + "quickPromptContent": "提示内容", + "quickPromptContentPlaceholder": "输入点击卡片后发送的提示词", + "quickPromptColor": "颜色", + "quickPromptIcon": "图标", + "quickPromptPreview": "预览", + "noQuickPrompts": "暂无快速启动卡片,点击上方按钮添加", + "noIcon": "无图标", "iconLabels": { "sparkles": "闪光", "globe": "地球", @@ -922,6 +952,14 @@ "sharedExpert": { "banner": "共享专家 · 由 {{name}} 提供" }, + "memoryMaintenance": { + "queued": "正在排队整理记忆库…", + "pruning": "正在清理过期记忆…", + "compacting": "正在压缩记忆库…", + "hintQueued": "其他智能体正在整理磁盘,本库排队中。现在仍可对话;轮到本库时会暂停发送。", + "hintBlocking": "整理期间此智能体暂时无法发送消息,完成后会自动恢复。", + "elapsed": "已进行 {{seconds}}s" + }, "thinking": "正在思考", "continuing": "继续生成中", "generating": "生成中", @@ -1296,7 +1334,7 @@ "skillContent": "技能内容", "pleaseInputName": "请输入技能名称", "pleaseInputContent": "请输入技能内容", - "skillNamePlaceholder": "例如:weather_query", + "skillNamePlaceholder": "例如:天气查询 / weather-query", "contentPlaceholder": "---\nname: <技能名称>(必填)\ndescription: <技能功能描述>(必填)\nmetadata: { \"octop\": { \"emoji\": \"🔧\" } }\n---\n\n技能实现内容...\n\n# 示例:\n# ---\n# name: cron\n# description: 通过 octop 命令管理定时任务 - 创建、查询、暂停、恢复、删除任务\n# metadata: { \"octop\": { \"emoji\": \"⏰\" } }\n# ---", "createSuccess": "技能创建成功", "importSuccess": "技能导入成功", @@ -1363,7 +1401,7 @@ "disableBeforeDelete": "请先禁用该技能后再删除", "applyNow": "立即应用", "nameLabel": "名称", - "namePattern": "仅支持字母 / 数字 / . _ -", + "namePattern": "名称支持中文等 Unicode 字符,但不能包含 / \\ : * ? \" < > | 等特殊字符,且不能以 . 开头(最长 64 字符)", "sourceLabel": "来源", "pathLabel": "路径", "metadataLabel": "Metadata", @@ -2658,7 +2696,8 @@ "violet": "紫罗兰", "emerald": "翠绿", "amber": "琥珀", - "slate": "石墨" + "slate": "石墨", + "custom": "自定义颜色" }, "currentVersion": "当前版本 v{{version}}", "currentVersionAdmin": "当前版本 v{{version}},点击查看更新", diff --git a/dashboard/src/pages/Agent/Memory/Overview.tsx b/dashboard/src/pages/Agent/Memory/Overview.tsx index 4ef7d988..8d15c732 100644 --- a/dashboard/src/pages/Agent/Memory/Overview.tsx +++ b/dashboard/src/pages/Agent/Memory/Overview.tsx @@ -431,6 +431,12 @@ function PipelineCard({ const raw = counts.raw_events ?? 0; const pending = counts.candidates_pending ?? 0; const atoms = counts.atoms ?? 0; + const lastExtract = counts.last_extract_run?.timestamp; + const lastExtractLabel = lastExtract + ? t("memory.pipeline.lastExtract", "上次整理 {{time}}", { + time: lastExtract.slice(0, 16).replace("T", " "), + }) + : null; const hint = raw === 0 ? t( @@ -455,7 +461,10 @@ function PipelineCard({ {t("memory.pipeline.title", "记忆处理进度")} - {hint} + + {hint} + {lastExtractLabel ? ` · ${lastExtractLabel}` : ""} +
{ + it("accepts CJK, letters, digits and . _ -", () => { + expect(isValidSkillName("天气查询")).toBe(true); + expect(isValidSkillName("weather-analysis")).toBe(true); + expect(isValidSkillName("weather_query.v2")).toBe(true); + }); + + it("rejects filesystem-hostile characters and leading dot", () => { + expect(isValidSkillName(".hidden")).toBe(false); + expect(isValidSkillName("a/b")).toBe(false); + expect(isValidSkillName("a\\b")).toBe(false); + expect(isValidSkillName('a:b*c?d"eg|h')).toBe(false); + expect(isValidSkillName("")).toBe(false); + expect(isValidSkillName("x".repeat(65))).toBe(false); + }); +}); + describe("SkillDrawer emoji metadata", () => { it("writes octop.emoji into frontmatter from the emoji field", () => { const md = buildSkillMarkdown({ diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx index bd9aa1c3..219e9c2b 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx @@ -44,6 +44,17 @@ export interface SkillFormValues { export const OCTOP_EMOJI_META_KEY = "octop.emoji"; +/** + * The skill name doubles as the workspace directory slug, so only + * filesystem-hostile characters are rejected - CJK and other Unicode + * letters are allowed. + */ +const SKILL_NAME_PATTERN = /^(?!\.)[^\\/:*?"<>|\x00-\x1f]{1,64}$/; + +export function isValidSkillName(name: string): boolean { + return SKILL_NAME_PATTERN.test(name.trim()); +} + function yamlQuote(value: string): string { if (!value) return '""'; if (/[:#\n"'{}[\],&*?|>!%@`]/.test(value) || value.trim() !== value) { @@ -401,9 +412,16 @@ export function SkillDrawer({ } const { body } = splitMarkdownFrontmatter(content); const fm = splitMarkdownFrontmatter(content).raw ?? ""; + const name = yamlTopLevel(fm, "name") || values.name; + // Edits keep the existing slug (updateSkill ignores the name), so only + // creation needs the slug check - legacy skills with odd names stay editable. + if (isCreate && !isValidSkillName(name)) { + message.warning(t("skills.namePattern")); + return; + } onSubmit({ ...values, - name: yamlTopLevel(fm, "name") || values.name, + name, description: yamlTopLevel(fm, "description") || values.description, body, content, @@ -431,8 +449,13 @@ export function SkillDrawer({ ? [ { required: true, message: t("skills.pleaseInputName") }, { - pattern: /^[a-zA-Z0-9._-]+$/, - message: t("skills.namePattern"), + validator: (_: unknown, value: string) => { + // Empty is reported by the required rule above. + if (!String(value ?? "").trim()) return Promise.resolve(); + return isValidSkillName(String(value)) + ? Promise.resolve() + : Promise.reject(new Error(t("skills.namePattern"))); + }, }, ] : undefined diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 898dfa8b..bd4788c4 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -522,31 +522,44 @@ const ChatInput = forwardRef( } }, [text, onUserInput]); - const adjustHeight = useCallback( - (animate = false) => { + const adjustHeight = useCallback(() => { const ta = textareaRef.current; if (!ta) return; - // Disable transition during measurement to avoid visual glitches + // Measure the content height on a detached clone instead of collapsing + // the live textarea to height:"auto". That transient shrink reflows the + // flex layout and makes the message list viewport (a sibling above the + // composer) grow for a moment; browsers clamp the list scrollTop to the + // larger viewport and the clamp STICKS after the height is restored — + // while a reply streams, follow-pins then snap the list back down, i.e. + // the per-keystroke up/down jitter. A clone never touches live layout. + const target = (() => { + const clone = ta.cloneNode(false) as HTMLTextAreaElement; + clone.value = ta.value; + const rect = ta.getBoundingClientRect(); + clone.style.cssText = [ + "position:fixed", + "left:-9999px", + "top:0", + "visibility:hidden", + "height:auto", + "min-height:0", + "max-height:none", + "transition:none", + `width:${rect.width}px`, + ].join(";"); + document.body.appendChild(clone); + const h = clone.scrollHeight; + document.body.removeChild(clone); + return Math.max(Math.min(h, 160), MIN_TEXTAREA_HEIGHT); + })(); + const current = ta.getBoundingClientRect().height; + if (Math.abs(target - current) < 0.5) return; // height unchanged + // Disable transition during the write so the resize is instant. ta.style.transition = "none"; - ta.style.height = "auto"; - const target = Math.max( - Math.min(ta.scrollHeight, 160), - MIN_TEXTAREA_HEIGHT, - ); - if (animate) { - // Snap to current rendered height first (no transition), then animate to target - const current = ta.getBoundingClientRect().height; - ta.style.height = `${current}px`; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - ta.offsetHeight; // force reflow - ta.style.transition = ""; - ta.style.height = `${target}px`; - } else { - ta.style.height = `${target}px`; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - ta.offsetHeight; - ta.style.transition = ""; - } + ta.style.height = `${target}px`; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + ta.offsetHeight; // force reflow + ta.style.transition = ""; }, [MIN_TEXTAREA_HEIGHT], ); diff --git a/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.module.less b/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.module.less new file mode 100644 index 00000000..f60e089a --- /dev/null +++ b/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.module.less @@ -0,0 +1,37 @@ +.banner { + flex: 0 0 auto; + margin: 8px var(--chat-column-pad-x, 24px) 0; + padding: 10px 14px 8px; + border-radius: 10px; + background: var(--fn-bg-secondary, #f5f5f7); + border: 1px solid var(--fn-border, #e8e8ed); + + @media (max-width: 767px) { + margin: 8px var(--chat-column-pad-x-narrow, 12px) 0; + } +} + +.titleRow { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} + +.title { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.meta { + font-size: 12px; + color: var(--fn-text-secondary); +} + +.hint { + margin: 4px 0 8px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-secondary); +} diff --git a/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.tsx b/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.tsx new file mode 100644 index 00000000..763e72c7 --- /dev/null +++ b/dashboard/src/pages/Chat/components/MemoryMaintenanceBanner.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { Progress } from "antd"; +import { useTranslation } from "react-i18next"; +import type { MemoryMaintenanceStatus } from "../hooks/useMemoryMaintenance"; +import styles from "./MemoryMaintenanceBanner.module.less"; + +function formatBytes(n?: number | null): string { + if (!n || n <= 0) return ""; + const gb = 1024 ** 3; + const mb = 1024 ** 2; + if (n >= gb) return `${(n / gb).toFixed(1)} GB`; + if (n >= mb) return `${Math.round(n / mb)} MB`; + return `${Math.max(1, Math.round(n / 1024))} KB`; +} + +interface MemoryMaintenanceBannerProps { + status: MemoryMaintenanceStatus; + blocking: boolean; +} + +export default function MemoryMaintenanceBanner({ + status, + blocking, +}: MemoryMaintenanceBannerProps) { + const { t } = useTranslation(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const elapsed = status.started_at + ? Math.max(0, Math.floor(now / 1000 - status.started_at)) + : 0; + const size = formatBytes(status.file_bytes); + const title = t(`chat.memoryMaintenance.${status.phase}`, { + defaultValue: t("chat.memoryMaintenance.compacting"), + }); + const hint = blocking + ? t("chat.memoryMaintenance.hintBlocking") + : t("chat.memoryMaintenance.hintQueued"); + + return ( +
+
+ {title} + {size ? {size} : null} + {elapsed > 0 ? ( + + {t("chat.memoryMaintenance.elapsed", { seconds: elapsed })} + + ) : null} +
+
{hint}
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts new file mode 100644 index 00000000..8762999c --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { request } from "../../../api/request"; + +export type MemoryMaintenancePhase = + | "idle" + | "queued" + | "pruning" + | "compacting" + | "done" + | "skipped"; + +export interface MemoryMaintenanceStatus { + phase: MemoryMaintenancePhase | string; + percent?: number; + detail?: string | null; + file_bytes?: number | null; + started_at?: number | null; + updated_at?: number | null; + skipped_reason?: string | null; +} + +const VISIBLE = new Set(["queued", "pruning", "compacting"]); +const BLOCKING = new Set(["pruning", "compacting"]); + +export function useMemoryMaintenance( + agentId: string | null | undefined, + enabled: boolean, +) { + const [status, setStatus] = useState(null); + + useEffect(() => { + if (!agentId || !enabled) { + setStatus(null); + return; + } + let stop = false; + const pull = () => { + request<{ memory_maintenance?: MemoryMaintenanceStatus | null }>( + `/agents/${agentId}/status`, + ) + .then((row) => { + if (!stop) setStatus(row.memory_maintenance ?? null); + }) + .catch(() => {}); + }; + pull(); + const timer = setInterval(pull, 2000); + return () => { + stop = true; + clearInterval(timer); + }; + }, [agentId, enabled]); + + const phase = status?.phase ?? "idle"; + return { + status, + visible: VISIBLE.has(phase), + blocking: BLOCKING.has(phase), + }; +} diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 36b622f1..f385f544 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -58,6 +58,8 @@ import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; import { isAgentChatReady } from "../../utils/agentError"; +import { useMemoryMaintenance } from "./hooks/useMemoryMaintenance"; +import MemoryMaintenanceBanner from "./components/MemoryMaintenanceBanner"; import { apiErrorMessage } from "../../utils/apiError"; import PwaInstallPrompt from "../../components/PwaInstallPrompt"; import { promptNeedsUserInput } from "../../utils/quickInputPrefill"; @@ -153,6 +155,11 @@ function ChatPageInner() { const agentChatReady = isAgentChatReady(activeAgent?.state); const sharedExpertViewer = isSharedExpertViewer(activeAgent ?? {}); const noAgents = !agentsLoading && agents.length === 0; + const { + status: memoryMaint, + visible: memoryMaintVisible, + blocking: memoryMaintBlocking, + } = useMemoryMaintenance(resolvedAgentId, agentChatReady && !noAgents); useEffect(() => { void refreshAgents({ silent: true }); @@ -784,6 +791,13 @@ function ChatPageInner() { /> )} + {memoryMaintVisible && memoryMaint && ( + + )} +
{!agentChatReady || noAgents ? ( { prefillInputRef.current = ""; diff --git a/dashboard/src/pages/Control/CronJobs/components/columns.tsx b/dashboard/src/pages/Control/CronJobs/components/columns.tsx index 08e446c0..62e1c229 100644 --- a/dashboard/src/pages/Control/CronJobs/components/columns.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/columns.tsx @@ -152,11 +152,10 @@ export const createColumns = ( { title: handlers.t("cronJobs.col.taskType"), key: "task_type", - width: 108, render: (_: unknown, record: CronJob) => { const taskType = record.task_type === "text" ? "text" : "agent"; return ( - + {taskType === "text" ? handlers.t("cronJobs.form.taskTypeText") : handlers.t("cronJobs.form.taskTypeAgent")} diff --git a/dashboard/src/pages/Control/CronJobs/index.module.less b/dashboard/src/pages/Control/CronJobs/index.module.less index 092ca358..7c434671 100644 --- a/dashboard/src/pages/Control/CronJobs/index.module.less +++ b/dashboard/src/pages/Control/CronJobs/index.module.less @@ -55,7 +55,9 @@ .promptCell { display: block; - max-width: 100%; + /* Table runs in `table-layout: auto` (scroll.x = max-content) so columns hug + their content — cap the prompt so a long instruction cannot stretch the row. */ + max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/dashboard/src/pages/Control/CronJobs/index.tsx b/dashboard/src/pages/Control/CronJobs/index.tsx index 735a8663..7071d49b 100644 --- a/dashboard/src/pages/Control/CronJobs/index.tsx +++ b/dashboard/src/pages/Control/CronJobs/index.tsx @@ -354,7 +354,7 @@ function CronJobsPage() { dataSource={jobs} rowKey="id" size="middle" - scroll={{ x: 1100 }} + scroll={{ x: "max-content" }} pagination={{ pageSize: 10, showSizeChanger: false, diff --git a/dashboard/src/pages/Control/TokenUsage/index.tsx b/dashboard/src/pages/Control/TokenUsage/index.tsx index 75b7a0c4..bc211809 100644 --- a/dashboard/src/pages/Control/TokenUsage/index.tsx +++ b/dashboard/src/pages/Control/TokenUsage/index.tsx @@ -263,8 +263,8 @@ function DailyTrendCharts({ outputLabel: string; turnsLabel: string; }) { - const { palette, isDark } = useTheme(); - const turnsColor = brandPrimary(palette, isDark); + const { palette, isDark, customColor } = useTheme(); + const turnsColor = brandPrimary(palette, isDark, customColor); const empty = data.length === 0; return ( diff --git a/dashboard/src/pages/Experts/components/AgentCard.tsx b/dashboard/src/pages/Experts/components/AgentCard.tsx index ecc5432a..02991a17 100644 --- a/dashboard/src/pages/Experts/components/AgentCard.tsx +++ b/dashboard/src/pages/Experts/components/AgentCard.tsx @@ -100,6 +100,8 @@ export const AgentCard = memo(function AgentCard({ Set >(() => new Set()); const pollRef = useRef | null>(null); + const [memorySlimming, setMemorySlimming] = useState(false); + const maintPollRef = useRef | null>(null); useEffect(() => { setLocalState(agent.state); @@ -136,6 +138,51 @@ export const AgentCard = memo(function AgentCard({ }; }, [localState, agent.agent_id, onStateChange, onPollSettled, refreshAgents]); + useEffect(() => { + if (localState !== "running") { + setMemorySlimming(false); + if (maintPollRef.current) { + clearInterval(maintPollRef.current); + maintPollRef.current = null; + } + return; + } + let cancelled = false; + const startedAt = Date.now(); + const pull = () => + request<{ + memory_maintenance?: { phase?: string } | null; + }>(`/agents/${agent.agent_id}/status`) + .then((s) => { + if (cancelled) return; + const phase = s.memory_maintenance?.phase; + const active = + phase === "queued" || phase === "pruning" || phase === "compacting"; + setMemorySlimming(!!active); + // First tick is ~1s after start; don't drop the poll on the + // idle snapshot before compact begins. Keep going while active. + if ( + !active && + Date.now() - startedAt > 15_000 && + maintPollRef.current + ) { + clearInterval(maintPollRef.current); + maintPollRef.current = null; + } + }) + .catch(() => {}); + const start = setTimeout(pull, 800); + maintPollRef.current = setInterval(pull, 2000); + return () => { + cancelled = true; + clearTimeout(start); + if (maintPollRef.current) { + clearInterval(maintPollRef.current); + maintPollRef.current = null; + } + }; + }, [localState, agent.agent_id]); + const isTransient = TRANSIENT.has(localState); const switchChecked = localState === "running" || localState === "starting"; @@ -332,6 +379,9 @@ export const AgentCard = memo(function AgentCard({ /> {formatAgentState(localState, t)}
+ {memorySlimming && ( + {t("experts.memorySlimming")} + )} setMbtiCatalogOpen(true) : undefined} diff --git a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx index 1797bea7..842df93e 100644 --- a/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx +++ b/dashboard/src/pages/Experts/components/CreateFromExpertDrawer.tsx @@ -20,8 +20,12 @@ import ExpertColorPicker from "../../../components/ExpertColorPicker"; import { apiErrorMessage } from "../../../utils/apiError"; import { expertPaletteColor, - resolveExpertPalette, + parseStoredColor, } from "../../../utils/expertColor"; +import { + DEFAULT_PALETTE, + isCuratedPalette, +} from "../../../styles/themePalettes"; import { buildAgentRuntimeRequest, type AgentRuntimeFormValues, @@ -33,7 +37,6 @@ import { defaultModelFromForm, MODEL_AUTO_VALUE, } from "../../../utils/modelOptions"; -import type { ThemePalette } from "../../../styles/themePalettes"; import type { ExpertSummary } from "./ExpertCard"; import { groupExpertFiles, type NamedFileContent } from "./expertFileGroups"; import { metaForFile } from "./iconForName"; @@ -139,7 +142,7 @@ export default function CreateFromExpertDrawer({ const [detailLoading, setDetailLoading] = useState(false); const [skillPackages, setSkillPackages] = useState([]); const [skillPackagesLoading, setSkillPackagesLoading] = useState(false); - const [colorPalette, setColorPalette] = useState("rose"); + const [colorPalette, setColorPalette] = useState("rose"); const backendChoice = Form.useWatch("backend_choice", form) ?? DEFAULT_BACKEND; @@ -157,7 +160,7 @@ export default function CreateFromExpertDrawer({ setPathMappings([]); const defaults = sourceDefaults(source, lang); - setColorPalette(resolveExpertPalette(defaults.color)); + setColorPalette(parseStoredColor(defaults.color) ?? DEFAULT_PALETTE); form.setFieldsValue({ name: defaults.name, description: defaults.description, @@ -259,7 +262,9 @@ export default function CreateFromExpertDrawer({ default_model: defaultModelFromForm(values.default_model) ?? undefined, backend: backendSpec, skill_package_ids: values.skill_package_ids ?? [], - color: expertPaletteColor(colorPalette), + color: isCuratedPalette(colorPalette) + ? expertPaletteColor(colorPalette) + : colorPalette, ...buildAgentRuntimeRequest(values), }; diff --git a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx index c1814bd3..59f26ae9 100644 --- a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx +++ b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx @@ -20,7 +20,7 @@ import { request } from "../../../api/request"; import { AgentAdvancedConfigFields } from "../../../components/AgentAdvancedConfigFields"; import ExpertColorPicker from "../../../components/ExpertColorPicker"; import { workspaceApi } from "../../../api/modules/workspace"; -import { apiErrorMessage } from "../../../utils/apiError"; +import { apiErrorMessage, parseApiError } from "../../../utils/apiError"; import { isAgentChatReady } from "../../../utils/agentError"; import { useAgentFormResources } from "../../../hooks/useAgentFormResources"; import type { OctopAgent } from "../../../context/AgentContext"; @@ -32,9 +32,12 @@ import { } from "../../../utils/modelOptions"; import { expertPaletteColor, - resolveExpertPalette, + parseStoredColor, } from "../../../utils/expertColor"; -import type { ThemePalette } from "../../../styles/themePalettes"; +import { + DEFAULT_PALETTE, + isCuratedPalette, +} from "../../../styles/themePalettes"; import { metaForFile } from "./iconForName"; import { buildAgentRuntimeRequest, @@ -43,6 +46,7 @@ import { } from "../../../utils/agentRuntimeConfig"; import { useSkillDisplayName } from "../../Agent/Skills/skillDisplayNames"; import FileEditModal from "./FileEditModal"; +import WelcomeConfig, { type WelcomeConfigRef } from "./WelcomeConfig"; import { fetchConfigMdFiles } from "./expertFileGroups"; import { buildBackendSpec, @@ -139,6 +143,44 @@ interface EditAgentDrawerBodyProps { onSavingChange: (saving: boolean) => void; } +/** + * Write a workspace file, retrying briefly when the agent's harness is mid-reload. + * + * The PATCH /agents/{aid} endpoint schedules a background ``arebuild_agent`` + * that briefly removes the agent from the registry and then re-creates it + * (slow graph compile, often 2-5s on Windows). Workspace writes go through + * ``require_running_workspace`` which raises ``AGENT_NOT_RUNNING`` during + * that absence window. The very first save in a session usually lands + * before the reload starts, but a follow-up save (the user re-opens the + * drawer, edits 页面配置, and clicks save again) hits the reload window. + * Backing off 500ms up to 10 times (~5s) is enough for typical agents; the + * manifest is best-effort so we let the caller's catch surface a warning + * rather than block the save. + */ +async function writeManifestWithRetry( + agentId: string, + path: string, + content: string, +): Promise { + const maxAttempts = 10; + const delayMs = 500; + let lastErr: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + await workspaceApi.createWorkspaceFile(agentId, path, content); + return; + } catch (err) { + lastErr = err; + const code = parseApiError(err)?.code; + if (code !== "AGENT_NOT_RUNNING") throw err; + if (attempt < maxAttempts - 1) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } + throw lastErr; +} + function EditAgentDrawerBody({ agent, onClose, @@ -157,8 +199,8 @@ function EditAgentDrawerBody({ useAgentFormResources(true); const [pathMappings, setPathMappings] = useState([]); const [agentConfig, setAgentConfig] = useState>({}); - const [colorPalette, setColorPalette] = useState(() => - resolveExpertPalette(agent.color), + const [colorPalette, setColorPalette] = useState( + () => parseStoredColor(agent.color) ?? DEFAULT_PALETTE, ); const [loading, setLoading] = useState(false); const [filesLoading, setFilesLoading] = useState(false); @@ -173,6 +215,7 @@ function EditAgentDrawerBody({ ); const [listRenameSaving, setListRenameSaving] = useState(false); const [subagentCatalogOpen, setSubagentCatalogOpen] = useState(false); + const welcomeConfigRef = useRef(null); const installedSubagentSlugs = useMemo( () => new Set(agentSubagents.map((s) => s.slug)), @@ -199,7 +242,7 @@ function EditAgentDrawerBody({ typeof cfg.color === "string" ? cfg.color : ag.color ?? agent.color ?? null; - setColorPalette(resolveExpertPalette(colorFromCfg)); + setColorPalette(parseStoredColor(colorFromCfg) ?? DEFAULT_PALETTE); const parsedBackend = parseBackendSpec(cfg.backend); setPathMappings(parsedBackend.pathMappings); @@ -301,13 +344,55 @@ function EditAgentDrawerBody({ values.root_dir, ); - const nextColor = expertPaletteColor(colorPalette); + const nextColor = isCuratedPalette(colorPalette) + ? expertPaletteColor(colorPalette) + : colorPalette; const nextConfig = omitAgentRuntimeConfig({ ...agentConfig, backend: backendSpec, color: nextColor, }); + // Save welcome config to manifest.json BEFORE patching the agent. + // The PATCH triggers a background harness reload which briefly removes + // the agent from the runtime; writing the workspace file after the + // PATCH can race with that reload and fail with "agent not running". + // + // The PATCH itself never returns AGENT_NOT_RUNNING (it reads the row, + // not the harness entry). The first save usually works because the + // agent is still loaded when we get here. But once a previous save's + // reload is still in flight (the harness arebuild_agent takes a few + // seconds to re-compile the graph), the manifest write below can land + // inside the "agent briefly absent from the registry" window and fail + // with AGENT_NOT_RUNNING — exactly when the user re-opens the drawer, + // expands 页面配置, edits, and saves again. So: retry briefly so the + // in-flight reload can re-register the agent, and fall back to a + // warning (not an error) so the agent's main config still saves. + if (welcomeConfigRef.current && isAgentChatReady(agent.state)) { + const data = welcomeConfigRef.current.getData(); + const manifest = { + welcome_message: data.welcome_message, + quick_prompts: data.quick_prompts.filter( + (p) => p.title?.zh || p.title?.en || p.prompt?.zh || p.prompt?.en, + ), + }; + const manifestJson = JSON.stringify(manifest, null, 2); + try { + await writeManifestWithRetry( + agent.agent_id, + "/manifest.json", + manifestJson, + ); + } catch (manifestErr) { + // Manifest is best-effort: the agent's main config (PATCH) is the + // important part. Surface a warning so the user knows, but never + // block the save because of a brief reload race. + message.warning( + apiErrorMessage(manifestErr, t("experts.manifestWriteFailed"), t), + ); + } + } + await request(`/agents/${agent.agent_id}`, { method: "PATCH", body: JSON.stringify({ @@ -319,6 +404,7 @@ function EditAgentDrawerBody({ ...buildAgentRuntimeRequest(values, { clearMissing: true }), }), }); + message.success(t("common.save") + " ✓"); if (bwrapToast?.kind === "success") { message.success(bwrapToast.text); @@ -342,6 +428,7 @@ function EditAgentDrawerBody({ } }, [ agent.agent_id, + agent.state, agentConfig, colorPalette, form, @@ -522,7 +609,7 @@ function EditAgentDrawerBody({
) : ( <> -
+
{t("experts.basicInfo")}
@@ -593,6 +680,7 @@ function EditAgentDrawerBody({ ghost className={styles.drawerCollapse} style={{ margin: "8px 0 0", width: "100%" }} + defaultActiveKey={["configFiles"]} items={[ { key: "advanced", @@ -603,17 +691,33 @@ function EditAgentDrawerBody({ ), }, + ...(isAgentChatReady(agent.state) + ? [ + { + key: "pageConfig", + label: t("experts.pageConfigTitle"), + children: ( +
+ +
+ ), + }, + ] + : []), ]} />
{isAgentChatReady(agent.state) && ( -
+
onChange?.(expertPaletteColor(palette))} + value={value ?? DEFAULT_PALETTE} + onChange={(next) => { + // Curated keys map to their swatch hex; custom hex passes through. + const hex = isCuratedPalette(next) ? expertPaletteColor(next) : next; + onChange?.(hex); + }} /> ); } diff --git a/dashboard/src/pages/Experts/components/WelcomeConfig.tsx b/dashboard/src/pages/Experts/components/WelcomeConfig.tsx new file mode 100644 index 00000000..a57a8594 --- /dev/null +++ b/dashboard/src/pages/Experts/components/WelcomeConfig.tsx @@ -0,0 +1,345 @@ +import { useState, useEffect, useCallback, useImperativeHandle, forwardRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Input } from "antd"; +import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"; +import { agentChatApi } from "@/api/modules/agentChat"; +import type { LocalizedText } from "@/utils/localizedText"; +import { iconForName } from "./iconForName"; +import { pastelIconBackground } from "@/utils/pastelIconBackground"; +import styles from "../index.module.less"; + +export interface QuickPrompt { + title: LocalizedText; + description: LocalizedText; + prompt: LocalizedText; + color: string; + icon_name: string | null; +} + +export interface WelcomeConfigData { + welcome_message?: LocalizedText; + quick_prompts: QuickPrompt[]; +} + +interface WelcomeConfigProps { + agentId: string; +} + + +const defaultQuickPrompt: QuickPrompt = { + title: { zh: "", en: "" }, + description: { zh: "", en: "" }, + prompt: { zh: "", en: "" }, + color: "#e8f4ff", + icon_name: null, +}; + +const presetColors = [ + "#e8f4ff", + "#eef2ff", + "#f0fdf4", + "#fff7ed", + "#fef3c7", + "#fdf2f8", + "#faf5ff", +]; + +// 图标名必须来自 iconForName 的 iconMap,否则会全部回退成默认图标造成重复。 +// 以下 16 个名字在 iconMap 中均存在且互不相同。 +const presetIcons = [ + "file-text", + "message-square", + "globe", + "sparkles", + "pen-tool", + "book-open", + "zap", + "bar-chart-3", + "list-todo", + "mail", + "hard-drive", + "palette", + "activity", + "video", + "terminal", + "wrench", +]; + +export interface WelcomeConfigRef { + getData: () => WelcomeConfigData; +} + +const WelcomeConfig = forwardRef( + ({ agentId }, ref) => { + const { t, i18n } = useTranslation(); + const [loading, setLoading] = useState(false); + const [welcomeMessage, setWelcomeMessage] = useState(""); + const [quickPrompts, setQuickPrompts] = useState([]); + + useImperativeHandle(ref, () => ({ + getData: () => ({ + welcome_message: welcomeMessage ? { + zh: welcomeMessage, + en: welcomeMessage, + } : undefined, + quick_prompts: quickPrompts, + }), + })); + + const loadConfig = useCallback(async () => { + setLoading(true); + try { + const data = await agentChatApi.welcome(agentId); + const currentLang = i18n.language.startsWith("zh") ? "zh" : "en"; + const wm = data.welcome_message; + const msg = wm ? (wm[currentLang] || wm.zh || wm.en || "") : ""; + setWelcomeMessage(msg || ""); + setQuickPrompts( + (data.quick_prompts || []).map((p) => ({ + title: { + zh: p.title?.zh ?? "", + en: p.title?.en ?? "", + }, + description: { + zh: p.description?.zh ?? "", + en: p.description?.en ?? "", + }, + prompt: { + zh: p.prompt?.zh ?? "", + en: p.prompt?.en ?? "", + }, + color: p.color || "#e8f4ff", + icon_name: p.icon_name ?? null, + })) + ); + } catch { + setWelcomeMessage(""); + setQuickPrompts([]); + } finally { + setLoading(false); + } + }, [agentId, i18n.language]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const addQuickPrompt = () => { + setQuickPrompts([...quickPrompts, { ...defaultQuickPrompt }]); + }; + + const removeQuickPrompt = (index: number) => { + setQuickPrompts(quickPrompts.filter((_, i) => i !== index)); + }; + + const updateQuickPrompt = ( + index: number, + field: keyof QuickPrompt, + value: any + ) => { + const newPrompts = [...quickPrompts]; + newPrompts[index] = { ...newPrompts[index], [field]: value }; + setQuickPrompts(newPrompts); + }; + + const updateLocalizedField = ( + index: number, + field: "title" | "description" | "prompt", + lang: "zh" | "en", + value: string + ) => { + const newPrompts = [...quickPrompts]; + const currentField = newPrompts[index][field]; + newPrompts[index] = { + ...newPrompts[index], + [field]: { + zh: currentField?.zh ?? "", + en: currentField?.en ?? "", + [lang]: value, + }, + }; + setQuickPrompts(newPrompts); + }; + + const currentLang = i18n.language.startsWith("zh") ? "zh" : "en"; + + return ( +
+ {loading ? ( +
{t("common.loading")}
+ ) : ( + <> +
+

{t("experts.welcomeMessageTitle")}

+
+ setWelcomeMessage(e.target.value)} + placeholder={t("experts.welcomeMessagePlaceholder")} + rows={2} + /> +
+
+ +
+
+

{t("experts.quickPromptsTitle")}

+ +
+ +
+ {quickPrompts.map((prompt, index) => ( +
+
+ {index + 1} +
+ +
+
+
+ + + updateLocalizedField(index, "title", currentLang, e.target.value) + } + placeholder={t("experts.quickPromptTitlePlaceholder")} + /> +
+
+ + + updateLocalizedField( + index, + "description", + currentLang, + e.target.value + ) + } + placeholder={t("experts.quickPromptDescriptionPlaceholder")} + /> +
+
+ +
+
+ + + updateLocalizedField(index, "prompt", currentLang, e.target.value) + } + placeholder={t("experts.quickPromptContentPlaceholder")} + rows={2} + /> +
+
+ +
+
+ +
+ {presetColors.map((color) => ( +
+
+
+ +
+ + {presetIcons.map((icon) => ( + + ))} +
+
+
+ + {quickPrompts.length > 0 && ( +
+
+ {t("experts.quickPromptPreview")} +
+
+
+ {iconForName(prompt.icon_name, 18)} +
+
+ + {prompt.title[currentLang] || + t("experts.quickPromptTitlePlaceholder")} + + + {prompt.description[currentLang] || + t("experts.quickPromptDescriptionPlaceholder")} + +
+
+
+ )} +
+
+ ))} + + {quickPrompts.length === 0 && ( +
+ {t("experts.noQuickPrompts")} +
+ )} +
+
+ + )} +
+ ); + } +); + +WelcomeConfig.displayName = "WelcomeConfig"; +export default WelcomeConfig; diff --git a/dashboard/src/pages/Experts/components/iconForName.tsx b/dashboard/src/pages/Experts/components/iconForName.tsx index 3a10c084..adaeafe4 100644 --- a/dashboard/src/pages/Experts/components/iconForName.tsx +++ b/dashboard/src/pages/Experts/components/iconForName.tsx @@ -42,6 +42,7 @@ import { Network, ShieldCheck, RefreshCw, + Utensils, } from "lucide-react"; const iconMap: Record ReactNode> = { @@ -75,6 +76,8 @@ const iconMap: Record ReactNode> = { network: (size) => , "shield-check": (size) => , "refresh-cw": (size) => , + utensils: (size) => , + bell: (size) => , }; export const EXPERT_ICON_NAMES = Object.keys(iconMap); diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index 3bd65e78..0ace361e 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1805,6 +1805,271 @@ flex-shrink: 0; } +/* ── WelcomeConfig Component ──────────────────────────────────────────────── */ + +.welcomeConfig { + display: flex; + flex-direction: column; + gap: 16px; + padding: 4px 0; +} + +.welcomeConfigHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.welcomeConfigHeader h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.welcomeConfigLoading { + padding: 24px; + text-align: center; + color: var(--fn-text-tertiary); +} + +.welcomeConfigSection { + display: flex; + flex-direction: column; + gap: 12px; +} + +.welcomeConfigSection h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-secondary); +} + +.welcomeMessageFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.welcomeMessageField { + display: flex; + flex-direction: column; + gap: 6px; +} + +.welcomeMessageField label { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.quickPromptsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.quickPromptsList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.quickPromptsEmpty { + padding: 16px; + text-align: center; + font-size: 13px; + color: var(--fn-text-tertiary); + border: 1px dashed var(--fn-border-secondary); + border-radius: var(--fn-radius-md); +} + +.quickPromptItem { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + background: var(--fn-bg-secondary); + border: 1px solid var(--fn-card-border-normal); + border-radius: var(--fn-radius-md); +} + +.quickPromptHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.quickPromptIndex { + font-size: 12px; + font-weight: 600; + color: var(--fn-text-tertiary); +} + +.quickPromptFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.quickPromptRow { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.quickPromptField { + flex: 1; + min-width: 160px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.quickPromptFieldFull { + flex: 1 1 100%; + min-width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} + +.quickPromptField label { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.colorPicker { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.colorOption { + width: 24px; + height: 24px; + border-radius: 6px; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.15s; +} + +.colorOption:hover { + transform: scale(1.1); +} + +.colorOptionActive { + border-color: var(--fn-color-brand); + box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.2); +} + +.iconPicker { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} + +.iconOption { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid var(--fn-border-primary); + background: var(--fn-bg-primary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s; + color: var(--fn-text-secondary); + font-size: 14px; +} + +.iconOption:hover { + border-color: var(--fn-color-brand); + background: var(--fn-sidebar-item-hover); +} + +/* "无图标" 按钮:文字宽度自适应,避免溢出固定 28px 的图标框 */ +.iconOptionNoIcon { + width: auto; + padding: 0 8px; + white-space: nowrap; +} + +.iconOptionActive { + border-color: var(--fn-color-brand); + background: var(--fn-color-brand-light, #e6f4ff); + color: var(--fn-color-brand); +} + +.quickPromptPreview { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--fn-border-secondary); +} + +.quickPromptPreviewLabel { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fn-text-tertiary); +} + +.quickCardPreview { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--fn-bg-primary); + border: 1px solid var(--fn-card-border-normal); + border-radius: var(--fn-radius-md); +} + +.quickCardIcon { + width: 32px; + height: 32px; + border-radius: var(--fn-radius-sm); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 16px; +} + +.quickCardBody { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.quickCardTitle { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quickCardDesc { + font-size: 12px; + color: var(--fn-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Publish expert drawer ─────────────────────────────────────── */ .publishDrawerBody { diff --git a/dashboard/src/pages/KnowledgeBases/index.module.less b/dashboard/src/pages/KnowledgeBases/index.module.less index 70ff2b04..3b903a48 100644 --- a/dashboard/src/pages/KnowledgeBases/index.module.less +++ b/dashboard/src/pages/KnowledgeBases/index.module.less @@ -17,6 +17,31 @@ line-height: 1.5; } +.onnxReadiness { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-md); + background: var(--fn-bg-primary); +} + +.onnxReadinessRow { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + line-height: 1.6; +} + +.onnxReadinessLabel { + flex: none; + min-width: 72px; + color: var(--fn-text-tertiary); +} + .onnxModelList { display: flex; flex-direction: column; diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 5baab2e7..2ccef88d 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -28,6 +28,7 @@ import { } from "antd"; import { message } from "@/utils/antdMessage"; import { + Check, ChevronLeft, Download, Eye, @@ -41,6 +42,7 @@ import { RefreshCw, Settings, Trash2, + X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -170,6 +172,32 @@ function KnowledgeIconPicker({ ); } +function ReadinessRow({ + label, + ok, + okText, + failText, +}: { + label: string; + ok: boolean; + okText: string; + failText: string; +}) { + return ( +
+ {label} + {ok ? ( + + ) : ( + + )} + + {ok ? okText : failText} + +
+ ); +} + export default function KnowledgeBasesPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -207,6 +235,13 @@ export default function KnowledgeBasesPage() { const [baseModalOpen, setBaseModalOpen] = useState(false); const [editingBase, setEditingBase] = useState(false); const [featureModalOpen, setFeatureModalOpen] = useState(false); + const [onnxProbe, setOnnxProbe] = useState<{ + ok: boolean; + latency_ms?: number | null; + dim?: number | null; + error?: string | null; + } | null>(null); + const [onnxProbing, setOnnxProbing] = useState(false); const [featureEnabledDraft, setFeatureEnabledDraft] = useState(false); const [featureModel, setFeatureModel] = useState(); const [featureBackend, setFeatureBackend] = useState<"onnx" | "remote">( @@ -570,6 +605,26 @@ export default function KnowledgeBasesPage() { } }; + // A probe describes one model; drop it as soon as the draft points elsewhere. + useEffect(() => { + setOnnxProbe(null); + }, [featureModel, featureBackend]); + + const runOnnxProbe = async () => { + if (!featureModel) return; + setOnnxProbing(true); + try { + setOnnxProbe(await knowledgeBasesApi.testOnnx(featureModel)); + } catch (error) { + setOnnxProbe({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + setOnnxProbing(false); + } + }; + const saveFeature = async (confirmed = false) => { if (!featureEnabledDraft) { try { @@ -1691,6 +1746,54 @@ export default function KnowledgeBasesPage() { ) : null}
)} + {featureBackend === "onnx" && featureModel ? ( +
+ + model.id === featureModel) + ?.downloaded, + )} + okText={t("knowledgeBases.checkDownloaded")} + failText={t("knowledgeBases.notDownloaded")} + /> +
+ + {t("knowledgeBases.checkEncode")} + + {onnxProbe ? ( + + {onnxProbe.ok + ? t("knowledgeBases.probeOk", { + dim: onnxProbe.dim ?? "?", + ms: Math.round(onnxProbe.latency_ms ?? 0), + }) + : onnxProbe.error ?? t("knowledgeBases.probeFailed")} + + ) : ( + + {t("knowledgeBases.probeIdle")} + + )} + +
+
+ ) : null} {t("knowledgeBases.enableDescription")} diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index 2b8b7bb2..9bab4e7b 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -366,20 +366,6 @@ export default function UpdateConfig() { )}
- {status?.has_update && status.release_notes && ( - , - }, - ]} - /> - )} - {progress && (
{progress.status === "running" && ( @@ -455,6 +441,20 @@ export default function UpdateConfig() { )}
)} + + {status?.has_update && status.release_notes && ( + , + }, + ]} + /> + )} diff --git a/dashboard/src/pages/Settings/Voice/index.tsx b/dashboard/src/pages/Settings/Voice/index.tsx index d19d8d1e..79d47215 100644 --- a/dashboard/src/pages/Settings/Voice/index.tsx +++ b/dashboard/src/pages/Settings/Voice/index.tsx @@ -39,7 +39,7 @@ export function VoiceSettingsPanel() { const [mimoEndpoint, setMimoEndpoint] = useState<"payg" | "tokenplan">( "payg", ); - const [mimoVoiceId, setMimoVoiceId] = useState("mimo_default"); + const [mimoVoiceId, setMimoVoiceId] = useState("冰糖"); const [saving, setSaving] = useState(false); const fetchAll = useCallback(async () => { @@ -100,7 +100,7 @@ export function VoiceSettingsPanel() { setSecretId(String(extra.secret_id ?? "")); setSecretKey(String(extra.secret_key ?? "")); setMimoEndpoint(extra.endpoint_type === "tokenplan" ? "tokenplan" : "payg"); - setMimoVoiceId(String(extra.voice_id ?? "mimo_default")); + setMimoVoiceId(String(extra.voice_id ?? "冰糖")); }; const handleSaveProvider = async () => { @@ -158,6 +158,15 @@ export function VoiceSettingsPanel() { setActive(next); invalidateVoiceConfigCache(); } + if ( + preset.kind !== "browser" && + (preset.capability === "tts" || preset.capability === "both") && + active.tts === "browser" + ) { + const next = await voiceApi.setActive({ tts: preset.id }); + setActive(next); + invalidateVoiceConfigCache(); + } message.success(t("voice.saved")); setConfigure(null); await fetchAll(); @@ -407,7 +416,6 @@ export function VoiceSettingsPanel() { value={mimoVoiceId} onChange={(v) => setMimoVoiceId(v)} options={[ - { value: "mimo_default", label: "MiMo Default" }, { value: "冰糖", label: "冰糖 (中文·女)" }, { value: "茉莉", label: "茉莉 (中文·女)" }, { value: "苏打", label: "苏打 (中文·男)" }, diff --git a/dashboard/src/styles/appearanceStorage.test.ts b/dashboard/src/styles/appearanceStorage.test.ts index 38a1d457..c46a87ad 100644 --- a/dashboard/src/styles/appearanceStorage.test.ts +++ b/dashboard/src/styles/appearanceStorage.test.ts @@ -5,6 +5,7 @@ import { writeStoredAppearance, } from "./appearanceStorage"; import { + DEFAULT_CUSTOM_COLOR, DEFAULT_PALETTE, LEGACY_PALETTE_STORAGE_KEY, THEME_STORAGE_KEY, @@ -17,20 +18,17 @@ afterEach(() => { describe("appearanceStorage", () => { it("defaults to system preference and rose palette", () => { - expect(readStoredAppearance()).toEqual({ - preference: "system", - palette: DEFAULT_PALETTE, - }); + expect(readStoredAppearance().preference).toBe("system"); + expect(readStoredAppearance().palette).toBe(DEFAULT_PALETTE); }); it("migrates legacy plain theme string + palette key", () => { localStorage.setItem(THEME_STORAGE_KEY, "dark"); localStorage.setItem(LEGACY_PALETTE_STORAGE_KEY, "tech"); - expect(readStoredAppearance()).toEqual({ - preference: "dark", - palette: "tech", - }); + const appearance = readStoredAppearance(); + expect(appearance.preference).toBe("dark"); + expect(appearance.palette).toBe("tech"); }); it("reads unified JSON under the theme key", () => { @@ -39,22 +37,54 @@ describe("appearanceStorage", () => { JSON.stringify({ preference: "light", palette: "indigo" }), ); - expect(readStoredAppearance()).toEqual({ - preference: "light", - palette: "indigo", - }); + const appearance = readStoredAppearance(); + expect(appearance.preference).toBe("light"); + expect(appearance.palette).toBe("indigo"); + }); + + it("reads the custom palette with its brand hex", () => { + localStorage.setItem( + THEME_STORAGE_KEY, + JSON.stringify({ + preference: "light", + palette: "custom", + customColor: "#AB5E50", + }), + ); + + const appearance = readStoredAppearance(); + expect(appearance.palette).toBe("custom"); + expect(appearance.customColor).toBe("#ab5e50"); + }); + + it("falls back to the default custom color on invalid hex", () => { + localStorage.setItem( + THEME_STORAGE_KEY, + JSON.stringify({ + preference: "light", + palette: "custom", + customColor: "not-a-color", + }), + ); + + expect(readStoredAppearance().customColor).toBe(DEFAULT_CUSTOM_COLOR); }); it("writes preference and palette as different fields in the same key", () => { writeStoredAppearance({ preference: "system", palette: "teal" }); localStorage.setItem(LEGACY_PALETTE_STORAGE_KEY, "should-be-removed"); - writeStoredAppearance({ preference: "light", palette: "violet" }); + writeStoredAppearance({ + preference: "light", + palette: "custom", + customColor: "#00AA55", + }); expect(localStorage.getItem(LEGACY_PALETTE_STORAGE_KEY)).toBeNull(); expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!)).toEqual({ preference: "light", - palette: "violet", + palette: "custom", + customColor: "#00aa55", }); }); @@ -62,31 +92,27 @@ describe("appearanceStorage", () => { localStorage.setItem(THEME_STORAGE_KEY, "dark"); localStorage.setItem(LEGACY_PALETTE_STORAGE_KEY, "amber"); - expect(loadAppearanceOnBoot()).toEqual({ - preference: "dark", - palette: "amber", - }); + const appearance = loadAppearanceOnBoot(); + expect(appearance.preference).toBe("dark"); + expect(appearance.palette).toBe("amber"); expect(localStorage.getItem(LEGACY_PALETTE_STORAGE_KEY)).toBeNull(); expect(JSON.parse(localStorage.getItem(THEME_STORAGE_KEY)!)).toEqual({ preference: "dark", palette: "amber", + customColor: DEFAULT_CUSTOM_COLOR.toLowerCase(), }); }); it("falls back safely on invalid JSON or unknown values", () => { localStorage.setItem(THEME_STORAGE_KEY, "{not-json"); - expect(readStoredAppearance()).toEqual({ - preference: "system", - palette: DEFAULT_PALETTE, - }); + expect(readStoredAppearance().preference).toBe("system"); + expect(readStoredAppearance().palette).toBe(DEFAULT_PALETTE); localStorage.setItem( THEME_STORAGE_KEY, JSON.stringify({ preference: "neon", palette: "pink" }), ); - expect(readStoredAppearance()).toEqual({ - preference: "system", - palette: DEFAULT_PALETTE, - }); + expect(readStoredAppearance().preference).toBe("system"); + expect(readStoredAppearance().palette).toBe(DEFAULT_PALETTE); }); }); diff --git a/dashboard/src/styles/appearanceStorage.ts b/dashboard/src/styles/appearanceStorage.ts index bad6d838..3aebc765 100644 --- a/dashboard/src/styles/appearanceStorage.ts +++ b/dashboard/src/styles/appearanceStorage.ts @@ -1,8 +1,10 @@ import { + DEFAULT_CUSTOM_COLOR, DEFAULT_PALETTE, LEGACY_PALETTE_STORAGE_KEY, THEME_STORAGE_KEY, VALID_PALETTES, + normalizeHexColor, type ThemePalette, } from "./themePalettes"; @@ -11,6 +13,8 @@ export type ThemePreference = "system" | "light" | "dark"; export type StoredAppearance = { preference: ThemePreference; palette: ThemePalette; + /** Brand hex for the "custom" palette; ignored for curated palettes. */ + customColor?: string; }; const VALID_PREFERENCES: ThemePreference[] = ["system", "light", "dark"]; @@ -23,7 +27,8 @@ function isPreference(value: unknown): value is ThemePreference { function isPalette(value: unknown): value is ThemePalette { return ( - typeof value === "string" && (VALID_PALETTES as string[]).includes(value) + typeof value === "string" && + ([...VALID_PALETTES, "custom"] as string[]).includes(value) ); } @@ -40,12 +45,20 @@ function readLegacyPalette(): ThemePalette { export function readStoredAppearance(): StoredAppearance { const raw = localStorage.getItem(THEME_STORAGE_KEY); if (!raw) { - return { preference: "system", palette: readLegacyPalette() }; + return { + preference: "system", + palette: readLegacyPalette(), + customColor: DEFAULT_CUSTOM_COLOR, + }; } // Legacy: plain preference string if (isPreference(raw)) { - return { preference: raw, palette: readLegacyPalette() }; + return { + preference: raw, + palette: readLegacyPalette(), + customColor: DEFAULT_CUSTOM_COLOR, + }; } try { @@ -58,18 +71,32 @@ export function readStoredAppearance(): StoredAppearance { const palette = isPalette(obj.palette) ? obj.palette : readLegacyPalette(); - return { preference, palette }; + const customColor = + normalizeHexColor(obj.customColor as string) ?? DEFAULT_CUSTOM_COLOR; + return { preference, palette, customColor }; } } catch { // fall through } - return { preference: "system", palette: readLegacyPalette() }; + return { + preference: "system", + palette: readLegacyPalette(), + customColor: DEFAULT_CUSTOM_COLOR, + }; } /** Persist both fields under the same `theme` key; drop legacy palette key. */ export function writeStoredAppearance(appearance: StoredAppearance): void { - localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(appearance)); + localStorage.setItem( + THEME_STORAGE_KEY, + JSON.stringify({ + preference: appearance.preference, + palette: appearance.palette, + customColor: + normalizeHexColor(appearance.customColor ?? "") ?? DEFAULT_CUSTOM_COLOR, + }), + ); localStorage.removeItem(LEGACY_PALETTE_STORAGE_KEY); } diff --git a/dashboard/src/styles/customPalette.test.ts b/dashboard/src/styles/customPalette.test.ts new file mode 100644 index 00000000..cba5f136 --- /dev/null +++ b/dashboard/src/styles/customPalette.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CUSTOM_COLOR, + brandPrimary, + brandTokensFor, + contrastRatio, + customBrandTokens, + customPaletteCssVars, + deriveCustomBrandColors, + mixHex, + normalizeHexColor, +} from "./themePalettes"; + +describe("normalizeHexColor", () => { + it("normalizes 3/6-digit hex with or without #", () => { + expect(normalizeHexColor("#4B74FA")).toBe("#4b74fa"); + expect(normalizeHexColor("4b74fa")).toBe("#4b74fa"); + expect(normalizeHexColor("#ABC")).toBe("#aabbcc"); + expect(normalizeHexColor(" abc ")).toBe("#aabbcc"); + }); + + it("rejects invalid input with null", () => { + expect(normalizeHexColor("")).toBeNull(); + expect(normalizeHexColor(null)).toBeNull(); + expect(normalizeHexColor("#12345")).toBeNull(); + expect(normalizeHexColor("#1234567")).toBeNull(); + expect(normalizeHexColor("red")).toBeNull(); + expect(normalizeHexColor("rgb(1,2,3)")).toBeNull(); + }); +}); + +describe("mixHex", () => { + it("interpolates between colors", () => { + expect(mixHex("#000000", "#ffffff", 0.5)).toBe("#808080"); + expect(mixHex("#000000", "#ffffff", 0)).toBe("#000000"); + expect(mixHex("#000000", "#ffffff", 1)).toBe("#ffffff"); + }); +}); + +describe("deriveCustomBrandColors", () => { + it("darkens light colors until white text is readable", () => { + const { solid } = deriveCustomBrandColors("#FFFF00"); // pure yellow + expect(contrastRatio(solid, "#FFFFFF")).toBeGreaterThanOrEqual(4.5); + }); + + it("keeps already-dark colors mostly unchanged", () => { + const { solid } = deriveCustomBrandColors("#1E3A8A"); + expect(contrastRatio(solid, "#FFFFFF")).toBeGreaterThanOrEqual(4.5); + // Stays in the same hue family (blue channel dominant). + const r = Number.parseInt(solid.slice(1, 3), 16); + const b = Number.parseInt(solid.slice(5, 7), 16); + expect(b).toBeGreaterThan(r); + }); + + it("brightens dark colors for dark-mode text", () => { + const { onDark } = deriveCustomBrandColors("#000000"); + expect(contrastRatio(onDark, "#0f1117")).toBeGreaterThanOrEqual(3); + }); +}); + +describe("customBrandTokens", () => { + it("produces WCAG-AA solid states for light mode", () => { + const tokens = customBrandTokens("#F87171").light; + for (const c of [ + tokens.colorPrimary, + tokens.colorPrimaryHover, + tokens.colorPrimaryActive, + ]) { + expect(contrastRatio(c, "#FFFFFF")).toBeGreaterThanOrEqual(4.5); + } + }); + + it("produces readable link colors for dark mode", () => { + const tokens = customBrandTokens("#7C2D12").dark; + expect(contrastRatio(tokens.colorLink, "#0f1117")).toBeGreaterThanOrEqual( + 3, + ); + }); +}); + +describe("brandTokensFor / brandPrimary with custom palette", () => { + it("derives tokens from the custom hex", () => { + const light = brandTokensFor("custom", false, "#FF0000"); + expect(light.colorPrimary).not.toBe("#FF0000"); // darkened for AA + expect(contrastRatio(light.colorPrimary, "#FFFFFF")).toBeGreaterThanOrEqual( + 4.5, + ); + }); + + it("falls back to the default custom color when none stored", () => { + expect(brandTokensFor("custom", true).colorLink).toBe( + customBrandTokens(DEFAULT_CUSTOM_COLOR).dark.colorLink, + ); + }); + + it("brandPrimary passes customColor through", () => { + expect(brandPrimary("custom", false, "#00FF00")).toBe( + brandTokensFor("custom", false, "#00FF00").colorPrimary, + ); + }); + + it("curated palettes ignore customColor", () => { + expect(brandPrimary("rose", false, "#00FF00")).toBe( + brandPrimary("rose", false), + ); + }); +}); + +describe("customPaletteCssVars", () => { + it("emits both light and dark blocks with the derived brand", () => { + const light = customPaletteCssVars("#4b74fa", false); + const dark = customPaletteCssVars("#4b74fa", true); + expect(light).toContain( + 'html[data-palette="custom"]:not([data-theme="dark"])', + ); + expect(dark).toContain('html[data-palette="custom"][data-theme="dark"]'); + expect(light).toContain("--fn-color-brand:"); + expect(dark).toContain("--fn-sidebar-item-active-text:"); + }); +}); diff --git a/dashboard/src/styles/themePalettes.ts b/dashboard/src/styles/themePalettes.ts index 09ab2424..fe6b3cae 100644 --- a/dashboard/src/styles/themePalettes.ts +++ b/dashboard/src/styles/themePalettes.ts @@ -8,7 +8,8 @@ export type ThemePalette = | "violet" | "emerald" | "amber" - | "slate"; + | "slate" + | "custom"; export const VALID_PALETTES: ThemePalette[] = [ "rose", @@ -21,7 +22,16 @@ export const VALID_PALETTES: ThemePalette[] = [ "slate", ]; +/** Curated palettes only — "custom" is handled separately via a hex value. */ +export const CURATED_PALETTES: ThemePalette[] = [...VALID_PALETTES]; + export const DEFAULT_PALETTE: ThemePalette = "rose"; +export const DEFAULT_CUSTOM_COLOR = "#4B74FA"; + +/** True when the value is one of the curated palette keys (not "custom"/hex). */ +export function isCuratedPalette(value: string): value is ThemePalette { + return (VALID_PALETTES as string[]).includes(value); +} /** Shared localStorage key for light/dark preference + brand palette. */ export const THEME_STORAGE_KEY = "theme"; @@ -42,6 +52,7 @@ export const PALETTE_SWATCH: Record = { emerald: "#10B981", amber: "#F59E0B", slate: "#64748B", + custom: DEFAULT_CUSTOM_COLOR, // live swatch is provided by the picker UI }; type AntdBrandTokens = { @@ -58,9 +69,9 @@ type AntdBrandTokens = { colorPrimaryTextActive?: string; }; -/** Ant Design primary tokens per palette × mode. */ +/** Ant Design primary tokens per curated palette × mode ("custom" derives at runtime). */ export const ANTD_BRAND_TOKENS: Record< - ThemePalette, + Exclude, { light: AntdBrandTokens; dark: AntdBrandTokens } > = { rose: { @@ -234,7 +245,223 @@ export const ANTD_BRAND_TOKENS: Record< }; /** Resolved Ant Design / chart primary for the active palette × mode. */ -export function brandPrimary(palette: ThemePalette, isDark: boolean): string { - const tokens = ANTD_BRAND_TOKENS[palette][isDark ? "dark" : "light"]; +export function brandPrimary( + palette: ThemePalette, + isDark: boolean, + customColor?: string | null, +): string { + const tokens = brandTokensFor(palette, isDark, customColor); return isDark ? tokens.colorLink : tokens.colorPrimary; } + +// --------------------------------------------------------------------------- +// Custom brand color — derive the full token/CSS-variable set from one hex +// --------------------------------------------------------------------------- + +/** Normalize user input (#abc / abc / #aabbcc / rgb-free hex) to #rrggbb. */ +export function normalizeHexColor( + input: string | null | undefined, +): string | null { + const raw = (input ?? "").trim().replace(/^#/, ""); + if (/^[0-9a-fA-F]{3}$/.test(raw)) { + return `#${raw + .split("") + .map((ch) => `${ch}${ch}`) + .join("") + .toLowerCase()}`; + } + if (/^[0-9a-fA-F]{6}$/.test(raw)) { + return `#${raw.toLowerCase()}`; + } + return null; +} + +function hexToRgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function rgbToHex(r: number, g: number, b: number): string { + const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v))); + return `#${((clamp(r) << 16) | (clamp(g) << 8) | clamp(b)) + .toString(16) + .padStart(6, "0")}`; +} + +/** Linear interpolation between two hex colors (t in [0,1]). */ +export function mixHex(a: string, b: string, t: number): string { + const [r1, g1, b1] = hexToRgb(a); + const [r2, g2, b2] = hexToRgb(b); + return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t); +} + +function relativeLuminance(hex: string): number { + const [r, g, b] = hexToRgb(hex).map((c) => { + const v = c / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG contrast ratio between two hex colors (1..21). */ +export function contrastRatio(a: string, b: string): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const lighter = Math.max(la, lb); + const darker = Math.min(la, lb); + return (lighter + 0.05) / (darker + 0.05); +} + +/** Darken until white text reaches ≥4.5:1 (WCAG AA); never below 12% lightness. */ +function ensureSolidOnWhite(hex: string): string { + let color = hex; + for (let i = 0; i < 10 && contrastRatio(color, "#FFFFFF") < 4.5; i++) { + color = mixHex(color, "#000000", 0.08); + } + return color; +} + +/** Lighten until readable on dark surfaces (≥3:1 against #0f1117). */ +function ensureTextOnDark(hex: string): string { + let color = hex; + for (let i = 0; i < 12 && contrastRatio(color, "#0f1117") < 3; i++) { + color = mixHex(color, "#FFFFFF", 0.12); + } + return color; +} + +export interface CustomBrandColors { + /** Darkened solid primary, readable with white text (light mode). */ + solid: string; + /** Brightened text/link variant for dark mode. */ + onDark: string; + /** Accent variant for badges/tags (original hue, mid luminance). */ + accent: string; +} + +export function deriveCustomBrandColors(hex: string): CustomBrandColors { + return { + solid: ensureSolidOnWhite(hex), + onDark: ensureTextOnDark(hex), + accent: ensureTextOnDark(mixHex(hex, "#FFFFFF", 0.12)), + }; +} + +/** Ant Design brand tokens for the custom palette, derived from one hex. */ +export function customBrandTokens(hex: string): { + light: AntdBrandTokens; + dark: AntdBrandTokens; +} { + const { solid, onDark } = deriveCustomBrandColors(hex); + const solidHover = mixHex(solid, "#000000", 0.1); + const solidActive = mixHex(solid, "#000000", 0.2); + return { + light: { + colorPrimary: solid, + colorPrimaryHover: solidHover, + colorPrimaryActive: solidActive, + colorLink: solid, + }, + dark: { + colorPrimary: solid, + colorPrimaryBg: `rgba(${hexToRgb(solid).join(", ")}, 0.12)`, + colorPrimaryBgHover: `rgba(${hexToRgb(solid).join(", ")}, 0.16)`, + colorPrimaryBorder: `rgba(${hexToRgb(solid).join(", ")}, 0.25)`, + colorPrimaryBorderHover: `rgba(${hexToRgb(solid).join(", ")}, 0.35)`, + colorPrimaryHover: mixHex(onDark, "#FFFFFF", 0.12), + colorPrimaryActive: solid, + colorPrimaryText: onDark, + colorPrimaryTextHover: mixHex(onDark, "#FFFFFF", 0.18), + colorPrimaryTextActive: onDark, + colorLink: onDark, + }, + }; +} + +/** Resolve Ant tokens for any palette — "custom" derives from the stored hex. */ +export function brandTokensFor( + palette: ThemePalette, + isDark: boolean, + customColor?: string | null, +): AntdBrandTokens { + if (palette === "custom") { + return customBrandTokens(customColor || DEFAULT_CUSTOM_COLOR)[ + isDark ? "dark" : "light" + ]; + } + return ANTD_BRAND_TOKENS[palette][isDark ? "dark" : "light"]; +} + +/** + * CSS custom-property overrides for `html[data-palette="custom"]`. + * Mirrors the curated palette blocks in theme-vars.css but derived at runtime. + */ +export function customPaletteCssVars(hex: string, isDark: boolean): string { + const { solid, onDark, accent } = deriveCustomBrandColors(hex); + const rgb = hexToRgb(solid).join(", "); + const rgbOnDark = hexToRgb(onDark).join(", "); + if (isDark) { + return `html[data-palette="custom"][data-theme="dark"]{ +--fn-bg-hover: rgba(${rgb}, 0.1); +--fn-bg-active: rgba(${rgb}, 0.16); +--fn-bg-selected: rgba(${rgb}, 0.12); +--fn-text-brand: ${onDark}; +--fn-logo-color: ${onDark}; +--fn-border-focus: ${onDark}; +--fn-color-brand: ${solid}; +--fn-color-brand-hover: ${mixHex(solid, "#000000", 0.1)}; +--fn-color-brand-soft: ${mixHex(solid, "#000000", 0.1)}; +--fn-color-brand-bg: rgba(${rgb}, 0.14); +--fn-color-brand-light: rgba(${rgb}, 0.18); +--fn-color-brand-shadow: rgba(${rgb}, 0.28); +--fn-color-brand-glow: rgba(${rgb}, 0.1); +--fn-assistant-bubble-bg-gradient: linear-gradient(135deg, rgba(${rgbOnDark}, 0.12) 0%, rgba(255, 255, 255, 0.04) 50%, rgba(${rgbOnDark}, 0.08) 100%); +--fn-assistant-bubble-border: rgba(${rgbOnDark}, 0.16); +--fn-assistant-glow-color: ${rgbOnDark}; +--fn-tag-channel-text: ${onDark}; +--fn-shadow-brand: 0 4px 14px rgba(${rgb}, 0.24); +--fn-shadow-brand-lg: 0 8px 24px rgba(${rgb}, 0.32); +--fn-row-selected-bg: rgba(${rgb}, 0.12); +--fn-row-selected-hover: rgba(${rgb}, 0.18); +--fn-row-selected-alt-bg: rgba(${rgb}, 0.1); +--fn-row-selected-alt-hover: rgba(${rgb}, 0.15); +--fn-row-selected-border: ${onDark}; +--fn-sidebar-item-active-bg: rgba(${rgb}, 0.16); +--fn-sidebar-item-active: rgba(${rgb}, 0.16); +--fn-sidebar-item-active-text: ${onDark}; +}`; + } + return `html[data-palette="custom"]:not([data-theme="dark"]){ +--fn-bg-hover: rgba(${rgb}, 0.04); +--fn-bg-active: rgba(${rgb}, 0.08); +--fn-bg-selected: rgba(${rgb}, 0.06); +--fn-text-brand: ${solid}; +--fn-logo-color: ${solid}; +--fn-border-focus: ${solid}; +--fn-color-brand: ${solid}; +--fn-color-brand-hover: ${mixHex(solid, "#000000", 0.1)}; +--fn-color-brand-soft: ${mixHex(solid, "#000000", 0.1)}; +--fn-color-brand-bg: rgba(${rgb}, 0.06); +--fn-color-brand-light: ${mixHex(solid, "#FFFFFF", 0.88)}; +--fn-color-brand-shadow: rgba(${rgb}, 0.15); +--fn-color-brand-glow: rgba(${rgb}, 0.08); +--fn-assistant-bubble-bg-gradient: linear-gradient(135deg, ${mixHex( + solid, + "#FFFFFF", + 0.9, + )} 0%, rgba(255, 255, 255, 0.35) 50%, ${mixHex(solid, "#FFFFFF", 0.82)} 100%); +--fn-assistant-bubble-border: rgba(${rgb}, 0.12); +--fn-assistant-glow-color: ${rgb}; +--fn-tag-channel-text: ${accent}; +--fn-shadow-brand: 0 4px 14px rgba(${rgb}, 0.18); +--fn-shadow-brand-lg: 0 8px 24px rgba(${rgb}, 0.24); +--fn-row-selected-bg: rgba(${rgb}, 0.05); +--fn-row-selected-hover: rgba(${rgb}, 0.09); +--fn-row-selected-alt-bg: rgba(${rgb}, 0.04); +--fn-row-selected-alt-hover: rgba(${rgb}, 0.08); +--fn-row-selected-border: ${solid}; +--fn-sidebar-item-active-bg: ${mixHex(solid, "#FFFFFF", 0.92)}; +--fn-sidebar-item-active: rgba(${rgb}, 0.08); +--fn-sidebar-item-active-text: ${solid}; +}`; +} diff --git a/dashboard/src/utils/expertColor.test.ts b/dashboard/src/utils/expertColor.test.ts index 3d35bd28..11510223 100644 --- a/dashboard/src/utils/expertColor.test.ts +++ b/dashboard/src/utils/expertColor.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; +import { PALETTE_SWATCH } from "../styles/themePalettes"; import { DEFAULT_SUBAGENT_ACCENT, expertPaletteColor, + parseStoredColor, resolveExpertPalette, resolveSubagentAccent, } from "./expertColor"; @@ -26,6 +28,30 @@ describe("resolveExpertPalette", () => { }); }); +describe("parseStoredColor", () => { + it("restores the curated key when the hex matches a swatch exactly", () => { + expect(parseStoredColor("#E85D75")).toBe("rose"); + expect(parseStoredColor("#e85d75")).toBe("rose"); + expect(parseStoredColor(PALETTE_SWATCH.slate)).toBe("slate"); + }); + + it("keeps a custom hex as-is (lowercased)", () => { + expect(parseStoredColor("#AB5E50")).toBe("#ab5e50"); + expect(parseStoredColor("#00aa55")).toBe("#00aa55"); + }); + + it("expands 3-digit hex before matching", () => { + expect(parseStoredColor("#F86")).toBe("#ff8866"); + }); + + it("returns null for missing or invalid values", () => { + expect(parseStoredColor(null)).toBeNull(); + expect(parseStoredColor("")).toBeNull(); + expect(parseStoredColor("not-a-color")).toBeNull(); + expect(parseStoredColor("orange")).toBeNull(); + }); +}); + describe("resolveSubagentAccent", () => { it("returns hex as-is", () => { expect(resolveSubagentAccent("#4B74FA")).toBe("#4B74FA"); diff --git a/dashboard/src/utils/expertColor.ts b/dashboard/src/utils/expertColor.ts index 0e155ee9..8004a2c6 100644 --- a/dashboard/src/utils/expertColor.ts +++ b/dashboard/src/utils/expertColor.ts @@ -2,6 +2,7 @@ import { DEFAULT_PALETTE, PALETTE_SWATCH, VALID_PALETTES, + normalizeHexColor, type ThemePalette, } from "../styles/themePalettes"; @@ -62,6 +63,23 @@ export function resolveExpertPalette( return best; } +/** + * Parse a stored color for the color-picker state: returns the curated + * palette key when the hex matches a swatch exactly, the normalized hex + * for any other valid color, and null when nothing usable is stored. + */ +export function parseStoredColor( + color: string | null | undefined, +): ThemePalette | string | null { + if (!color) return null; + const normalized = normalizeHexColor(color); + if (!normalized) return null; + for (const key of VALID_PALETTES) { + if (PALETTE_SWATCH[key].toLowerCase() === normalized) return key; + } + return normalized; +} + export function expertPaletteColor(palette: ThemePalette): string { return PALETTE_SWATCH[palette]; } diff --git a/dashboard/src/utils/wavStreamPlayer.test.ts b/dashboard/src/utils/wavStreamPlayer.test.ts new file mode 100644 index 00000000..376bd2c4 --- /dev/null +++ b/dashboard/src/utils/wavStreamPlayer.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { parseWavHeader, pcm16ToFloat32 } from "./wavStreamPlayer"; + +function wavHeaderBytes( + sampleRate = 24000, + channels = 1, + dataLen = 8, +): Uint8Array { + const bytes = new Uint8Array(44); + const dv = new DataView(bytes.buffer); + const ascii = (offset: number, s: string) => { + for (let i = 0; i < s.length; i++) bytes[offset + i] = s.charCodeAt(i); + }; + ascii(0, "RIFF"); + dv.setUint32(4, 36 + dataLen, true); + ascii(8, "WAVE"); + ascii(12, "fmt "); + dv.setUint32(16, 16, true); + dv.setUint16(20, 1, true); // PCM + dv.setUint16(22, channels, true); + dv.setUint32(24, sampleRate, true); + dv.setUint32(28, sampleRate * channels * 2, true); + dv.setUint16(32, channels * 2, true); + dv.setUint16(34, 16, true); + ascii(36, "data"); + dv.setUint32(40, dataLen, true); + return bytes; +} + +describe("parseWavHeader", () => { + it("parses a canonical 44-byte PCM header", () => { + const fmt = parseWavHeader(wavHeaderBytes()); + expect(fmt).not.toBeNull(); + expect(fmt?.sampleRate).toBe(24000); + expect(fmt?.channels).toBe(1); + expect(fmt?.bitsPerSample).toBe(16); + expect(fmt?.headerLength).toBe(44); + }); + + it("rejects short buffers", () => { + expect(parseWavHeader(new Uint8Array(10))).toBeNull(); + }); + + it("rejects non-WAV payloads", () => { + const mp3 = new TextEncoder().encode( + "ID3SOMEDATASOMEDATASOMEDATASOMEDATASOMED", + ); + expect(parseWavHeader(mp3)).toBeNull(); + }); +}); + +describe("pcm16ToFloat32", () => { + it("decodes little-endian int16 to [-1, 1] floats", () => { + const bytes = new Uint8Array([0x00, 0x00, 0x00, 0x80, 0x00, 0x7f]); + const out = pcm16ToFloat32(bytes); + expect(out).toHaveLength(3); + expect(out[0]).toBe(0); + expect(out[1]).toBeCloseTo(-1); + expect(out[2]).toBeCloseTo(32512 / 32768); + }); + + it("drops a trailing odd byte", () => { + expect(pcm16ToFloat32(new Uint8Array(5))).toHaveLength(2); + }); +}); + +describe("WavStreamPlayer frame buffering (node env without AudioContext)", () => { + it("pushing a non-WAV first chunk reports unsupported", async () => { + const { WavStreamPlayer } = await import("./wavStreamPlayer"); + const player = new WavStreamPlayer(); + const mp3 = new TextEncoder().encode("ID3..."); + expect(player.push(mp3)).toBe(false); + }); + + it("validates header and buffers partial frames via push/carry", async () => { + const { WavStreamPlayer } = await import("./wavStreamPlayer"); + const player = new WavStreamPlayer(); + // No AudioContext in node — push() should still parse the header and + // buffer partial frames without throwing. + const header = wavHeaderBytes(); + const odd = new Uint8Array(3); + expect(player.push(header)).toBe(true); + expect(player.push(odd)).toBe(true); + expect(player.hasAudio).toBe(false); + expect(player.push(new Uint8Array(1))).toBe(true); + // hasAudio stays false in node (no context to schedule on). + player.stop(); + }); +}); diff --git a/dashboard/src/utils/wavStreamPlayer.ts b/dashboard/src/utils/wavStreamPlayer.ts new file mode 100644 index 00000000..3f5435bf --- /dev/null +++ b/dashboard/src/utils/wavStreamPlayer.ts @@ -0,0 +1,190 @@ +/** + * Incremental WAV/PCM playback for streaming TTS responses. + * + * The server streams MiMo TTS as a WAV (24kHz PCM16LE mono) whose data-size + * field is a max-size sentinel — a blob/