diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index b50892b3..566c3d8c 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -695,6 +695,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", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 134b8c59..f19054b2 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -695,6 +695,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": "地球", diff --git a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx index c1814bd3..5841c739 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"; @@ -43,6 +43,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 +140,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, @@ -173,6 +212,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)), @@ -308,6 +348,42 @@ function EditAgentDrawerBody({ 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 +395,7 @@ function EditAgentDrawerBody({ ...buildAgentRuntimeRequest(values, { clearMissing: true }), }), }); + message.success(t("common.save") + " ✓"); if (bwrapToast?.kind === "success") { message.success(bwrapToast.text); @@ -342,6 +419,7 @@ function EditAgentDrawerBody({ } }, [ agent.agent_id, + agent.state, agentConfig, colorPalette, form, @@ -522,7 +600,7 @@ function EditAgentDrawerBody({ ) : ( <> -
+
{t("experts.basicInfo")}
@@ -593,6 +671,7 @@ function EditAgentDrawerBody({ ghost className={styles.drawerCollapse} style={{ margin: "8px 0 0", width: "100%" }} + defaultActiveKey={["configFiles"]} items={[ { key: "advanced", @@ -603,17 +682,29 @@ function EditAgentDrawerBody({ ), }, + ...(isAgentChatReady(agent.state) ? [{ + key: "pageConfig", + label: t("experts.pageConfigTitle"), + children: ( +
+ +
+ ), + }] : []), ]} />
{isAgentChatReady(agent.state) && ( -
+
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/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 {