From bfe705b134ef38c6c5760cf8284383ef0f9e090e Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Wed, 22 Jul 2026 16:29:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webui):=20inline=20deployment=20creati?= =?UTF-8?q?on=20&=20rename=20schedule=20=E2=86=92=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move deployment creation from /schedule page into an inline popover on the DeploymentButton, so users never leave the composer context - /deployments route is now a pure management page (list, pause, run, delete) - Rename all schedule references to deployment: - ScheduleButton → DeploymentButton, ScheduleCenter → DeploymentCenter - lib/schedule.ts → lib/deployment.ts, CSS schedule-* → deploy-* - Route /schedule → /deployments, TopBar label '定时' → '定时任务' - Add success state with 'go to deployments' navigation after creation - Improve run button: Play icon + label + hover tooltip on all actions - Fix: useId() for unique form element ids (two instances in DOM) - Fix: try/catch/finally on API calls to prevent stuck busy state - Fix: mobile popover animation vs transform conflict Change-Id: I99e7ae5bc3256fe558675aaa4fb6bb1d301c7854 Co-developed-by: OpenCode --- apps/webui/src/App.tsx | 31 +- .../src/app/{schedule.css => deployment.css} | 392 +++++++++--------- apps/webui/src/app/desktop.css | 30 +- apps/webui/src/app/mobile.css | 22 +- apps/webui/src/components/BottomBar.tsx | 10 +- apps/webui/src/components/Composer.tsx | 10 +- .../webui/src/components/DeploymentButton.tsx | 184 ++++++++ .../webui/src/components/DeploymentCenter.tsx | 145 +++++++ apps/webui/src/components/ScheduleButton.tsx | 69 --- apps/webui/src/components/ScheduleCenter.tsx | 263 ------------ apps/webui/src/components/TopBar.tsx | 8 +- .../src/lib/{schedule.ts => deployment.ts} | 6 +- apps/webui/src/lib/topbar-route.ts | 26 +- apps/webui/src/main.tsx | 2 +- .../{schedule.test.ts => deployment.test.ts} | 10 +- apps/webui/tests/topbar-route.test.ts | 24 +- 16 files changed, 608 insertions(+), 624 deletions(-) rename apps/webui/src/app/{schedule.css => deployment.css} (57%) create mode 100644 apps/webui/src/components/DeploymentButton.tsx create mode 100644 apps/webui/src/components/DeploymentCenter.tsx delete mode 100644 apps/webui/src/components/ScheduleButton.tsx delete mode 100644 apps/webui/src/components/ScheduleCenter.tsx rename apps/webui/src/lib/{schedule.ts => deployment.ts} (86%) rename apps/webui/tests/{schedule.test.ts => deployment.test.ts} (54%) diff --git a/apps/webui/src/App.tsx b/apps/webui/src/App.tsx index 1dec630..e0bc552 100644 --- a/apps/webui/src/App.tsx +++ b/apps/webui/src/App.tsx @@ -2,13 +2,13 @@ import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import BottomBar, { type BottomBarHandle } from "@/components/BottomBar"; import Composer, { type ComposerHandle } from "@/components/Composer"; import ConfirmDialog from "@/components/ConfirmDialog"; +import DeploymentCenter from "@/components/DeploymentCenter"; import GlobalToastHost from "@/components/GlobalToastHost"; import HeroGreeting from "@/components/HeroGreeting"; import PromptDialog from "@/components/PromptDialog"; import { PromptEditorProvider } from "@/components/prompt-editor/PromptEditorProvider"; import RoleCards from "@/components/RoleCards"; import ResourceCenter from "@/components/resource-center"; -import ScheduleCenter from "@/components/ScheduleCenter"; import SettingsDialog from "@/components/SettingsDialog"; import Showcase from "@/components/Showcase"; import TopBar from "@/components/TopBar"; @@ -19,7 +19,6 @@ import { useAgentsConfigReady } from "@/lib/hooks/useAgentsConfigReady"; import { getRoleCards } from "@/lib/playbooks"; import type { RoleCard } from "@/lib/playbooks/types"; import { isPlaygroundMode } from "@/lib/runtime-mode"; -import type { SchedulePreset } from "@/lib/schedule"; import { useProviderConfigRevision } from "@/lib/store/provider-config-store"; import { useTopBarView } from "@/lib/use-topbar-view"; @@ -75,12 +74,6 @@ export default function Home() { const [models, setModels] = useState([]); const [selectedModelsByAgent, setSelectedModelsByAgent] = useState>({}); const [warmProgress, setWarmProgress] = useState(null); - const [scheduleDraft, setScheduleDraft] = useState<{ - key: number; - preset?: SchedulePreset; - prompt: string; - playbookId: string; - }>({ key: 0, prompt: "", playbookId: "" }); // Only read inside handlers (top composer vs. bottom bar routing), never rendered — a ref avoids // re-rendering the whole page each time the bar scrolls in or out of view. const bottomBarVisibleRef = useRef(false); @@ -185,24 +178,10 @@ export default function Home() { [activeAgentSlug], ); - const handleOpenSchedule = useCallback( - (preset?: SchedulePreset) => { - setScheduleDraft((current) => ({ - key: current.key + 1, - preset, - prompt: inputValue, - playbookId: activeAgentSlug, - })); - setView("schedule"); - window.scrollTo({ top: 0, behavior: "smooth" }); - }, - [activeAgentSlug, inputValue, setView], - ); - return ( - {view === "resources" || view === "schedule" ? ( + {view === "resources" || view === "deployments" ? ( <>
@@ -213,7 +192,7 @@ export default function Home() { settingsOpen={settingsOpen} onOpenSettings={() => setSettingsOpen(true)} /> - {view === "resources" ? : } + {view === "resources" ? : }
@@ -261,7 +240,7 @@ export default function Home() { models={models} onModelChange={handleModelChange} onMakeSame={handleMakeSame} - onOpenSchedule={handleOpenSchedule} + onNavigate={setView} canSubmit={canSubmit} /> @@ -282,7 +261,7 @@ export default function Home() { composerRef={composerRef as React.RefObject} onVisibilityChange={handleBottomBarVisibility} onMakeSame={handleMakeSame} - onOpenSchedule={handleOpenSchedule} + onNavigate={setView} canSubmit={canSubmit} /> diff --git a/apps/webui/src/app/schedule.css b/apps/webui/src/app/deployment.css similarity index 57% rename from apps/webui/src/app/schedule.css rename to apps/webui/src/app/deployment.css index 24d79d1..6acaead 100644 --- a/apps/webui/src/app/schedule.css +++ b/apps/webui/src/app/deployment.css @@ -1,4 +1,5 @@ -.schedule-trigger { +/* ── Deployment trigger button ── */ +.deploy-trigger { position: relative; display: inline-flex; align-items: center; @@ -17,246 +18,237 @@ border-color 0.18s ease; } -.schedule-trigger:hover, -.schedule-trigger.active { +.deploy-trigger:hover, +.deploy-trigger.active { background: var(--soft); border-color: var(--line-2); } -.schedule-menu-wrap { +.deploy-menu-wrap { position: relative; z-index: 25; display: inline-flex; } -.schedule-menu { +/* ── Inline popover (create form) ── */ +.deploy-popover { position: absolute; left: 0; bottom: calc(100% + 12px); - width: 260px; - padding: 5px; + width: 300px; border: 1px solid var(--line); - border-radius: 12px; + border-radius: 14px; background: #ffffff; box-shadow: 0 18px 50px rgba(11, 12, 15, 0.14); + animation: deploy-pop-in 0.15s ease; } -.schedule-menu::after { - content: ""; - position: absolute; - left: 22px; - bottom: -7px; - width: 12px; - height: 12px; - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); - background: #ffffff; - transform: rotate(45deg); +@keyframes deploy-pop-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.composer .schedule-menu { - top: 100%; +.composer .deploy-popover { bottom: auto; + top: calc(100% + 8px); } -.composer .schedule-menu::after { - top: -7px; - bottom: auto; - border: 0; - border-left: 1px solid var(--line); - border-top: 1px solid var(--line); +.deploy-popover-body { + padding: 14px 14px 8px; + display: flex; + flex-direction: column; + gap: 10px; } -.schedule-menu-primary, -.schedule-menu-item { - width: 100%; - height: 40px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--ink); +.deploy-popover-field { display: flex; - align-items: center; - gap: 14px; - padding: 0 14px; - font-size: 15px; - font-weight: 650; - text-align: left; + flex-direction: column; + gap: 5px; } -.schedule-menu-item { - font-weight: 600; +.deploy-popover-field label { + color: var(--muted); + font-size: 11px; + font-weight: 700; } -.schedule-menu-primary:hover, -.schedule-menu-item:hover { +.deploy-popover-field input, +.deploy-popover-field select { + width: 100%; + height: 36px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; background: var(--soft); + color: var(--ink); + font: inherit; + font-size: 13px; + outline: none; + transition: + background 0.18s ease, + border-color 0.18s ease; } -.schedule-menu-divider { - height: 1px; - margin: 4px 0; - background: var(--line); +.deploy-popover-field input:focus, +.deploy-popover-field select:focus { + background: #ffffff; + border-color: var(--ink); } -.schedule-page { - width: min(100%, 1120px); - margin: 0 auto; - padding: 54px 28px 80px; +.deploy-popover-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; } -.schedule-hero { +.deploy-popover-presets { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 24px; - margin-bottom: 28px; -} - -.schedule-kicker { - margin: 0 0 8px; - color: var(--muted); - font-size: 13px; - font-weight: 700; + flex-wrap: wrap; + gap: 8px; } -.schedule-hero h1 { +.deploy-popover-message { margin: 0; - font-size: 42px; - line-height: 1.08; - letter-spacing: 0; + padding: 4px 14px 0; + color: var(--muted); + font-size: 12px; } -.schedule-subtitle { - margin: 12px 0 0; - max-width: 560px; - color: var(--muted); - font-size: 15px; - line-height: 1.7; +.deploy-popover-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 8px 14px 12px; } -.schedule-create-btn { - height: 40px; - padding: 0 18px; +.deploy-popover-submit { + height: 34px; + padding: 0 16px; border: 0; border-radius: 999px; background: var(--cta); color: #ffffff; - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 14px; + font-size: 13px; font-weight: 700; - white-space: nowrap; } -.schedule-builder, -.schedule-list { - border: 1px solid var(--line); - border-radius: 12px; - background: #ffffff; - box-shadow: var(--shadow); +.deploy-popover-submit:disabled { + opacity: 0.6; } -.schedule-builder { - padding: 20px; - margin-bottom: 22px; +/* ── Success state after creation ── */ +.deploy-popover-success { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 24px 16px; + text-align: center; } -.schedule-feedback, -.schedule-empty { - margin: 0 0 16px; - padding: 12px 14px; - border-radius: 10px; - background: var(--surface-soft); - color: var(--muted); - font-size: 14px; +.deploy-popover-success svg { + color: var(--green, #22c55e); } -.builder-grid { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - gap: 14px; - margin-top: 14px; +.deploy-popover-success span { + font-size: 14px; + font-weight: 650; + color: var(--ink); } -.builder-field { - display: flex; - flex-direction: column; - gap: 8px; +.deploy-popover-goto { + height: 34px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: var(--cta); + color: #ffffff; + font-size: 13px; + font-weight: 700; + margin-top: 4px; } -.builder-field label { - color: var(--muted); +/* ── Preset pills (shared between popover and old page) ── */ +.deploy-preset { + height: 30px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: #ffffff; + color: var(--ink); + display: inline-flex; + align-items: center; + gap: 6px; font-size: 12px; - font-weight: 700; + font-weight: 650; } -.builder-field textarea, -.builder-field input, -.builder-field select { - width: 100%; - border: 1px solid var(--line); - border-radius: 10px; +.deploy-preset:hover { background: var(--soft); - color: var(--ink); - font: inherit; - outline: none; - transition: - background 0.18s ease, - border-color 0.18s ease; } -.builder-field textarea:focus, -.builder-field input:focus, -.builder-field select:focus { - background: #ffffff; - border-color: var(--ink); +/* ── /deployments management page ── */ +.deploy-page { + width: min(100%, 1120px); + margin: 0 auto; + padding: 54px 28px 80px; } -.builder-field textarea { - min-height: 98px; - padding: 14px; - resize: vertical; - line-height: 1.6; +.deploy-hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 28px; } -.builder-field input, -.builder-field select { - height: 42px; - padding: 0 12px; +.deploy-kicker { + margin: 0 0 8px; + color: var(--muted); + font-size: 13px; + font-weight: 700; } -.schedule-preset-row { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 16px; +.deploy-hero h1 { + margin: 0; + font-size: 42px; + line-height: 1.08; + letter-spacing: 0; } -.schedule-preset { - height: 34px; - padding: 0 12px; - border: 1px solid var(--line); - border-radius: 999px; - background: #ffffff; - color: var(--ink); - display: inline-flex; - align-items: center; - gap: 7px; - font-size: 13px; - font-weight: 650; +.deploy-subtitle { + margin: 12px 0 0; + max-width: 560px; + color: var(--muted); + font-size: 15px; + line-height: 1.7; } -.schedule-preset:hover { - background: var(--soft); +.deploy-feedback, +.deploy-empty { + margin: 0 0 16px; + padding: 12px 14px; + border-radius: 10px; + background: var(--surface-soft); + color: var(--muted); + font-size: 14px; } -.schedule-list { +.deploy-list { + border: 1px solid var(--line); + border-radius: 12px; + background: #ffffff; + box-shadow: var(--shadow); overflow: hidden; } -.schedule-list-head { +.deploy-list-head { display: flex; align-items: center; justify-content: space-between; @@ -265,23 +257,23 @@ border-bottom: 1px solid var(--line); } -.schedule-list-head h2 { +.deploy-list-head h2 { margin: 0; font-size: 18px; line-height: 1.2; } -.schedule-list-head span { +.deploy-list-head span { color: var(--muted); font-size: 13px; } -.schedule-table { +.deploy-table { display: flex; flex-direction: column; } -.schedule-row { +.deploy-row { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.9fr) auto; align-items: center; @@ -290,35 +282,35 @@ border-bottom: 1px solid var(--line); } -.schedule-row:last-child { +.deploy-row:last-child { border-bottom: 0; } -.schedule-row-title { +.deploy-row-title { display: flex; align-items: center; gap: 8px; } -.schedule-row-title svg { +.deploy-row-title svg { color: var(--green); flex-shrink: 0; } -.schedule-row-title h3 { +.deploy-row-title h3 { margin: 0; font-size: 15px; line-height: 1.3; } -.schedule-row-main p { +.deploy-row-main p { margin: 8px 0 0 25px; color: var(--muted); font-size: 13px; line-height: 1.6; } -.schedule-meta { +.deploy-meta { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; @@ -326,20 +318,20 @@ font-size: 12px; } -.schedule-meta span { +.deploy-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.schedule-actions { +.deploy-actions { display: flex; align-items: center; gap: 6px; } -.schedule-actions button { +.deploy-actions button { width: 34px; height: 34px; border: 1px solid var(--line); @@ -350,59 +342,73 @@ place-items: center; } -.schedule-actions button:hover { +.deploy-actions button:hover { background: var(--soft); color: var(--ink); } +.deploy-action-run { + width: auto !important; + padding: 0 12px !important; + gap: 5px; + display: inline-flex !important; + align-items: center; + font-size: 12px; + font-weight: 650; + color: var(--cta) !important; + border-color: var(--cta) !important; +} + +.deploy-action-run:hover { + background: var(--cta) !important; + color: #ffffff !important; +} + @media (max-width: 720px) { - .schedule-trigger span { + .deploy-trigger span { display: inline; } - .schedule-menu { - left: 0; - width: min(280px, calc(100vw - 32px)); + .deploy-popover { + position: fixed; + left: 16px; + right: 16px; + bottom: auto; + top: 50%; + transform: translateY(-50%); + width: auto; + animation: none; } - .schedule-page { + .deploy-page { padding: 24px 16px calc(120px + env(safe-area-inset-bottom)); } - .schedule-hero { + .deploy-hero { align-items: flex-start; flex-direction: column; gap: 16px; } - .schedule-hero h1 { + .deploy-hero h1 { font-size: 32px; } - .schedule-create-btn { - width: 100%; - justify-content: center; - } - - .builder-grid { - grid-template-columns: 1fr; - } - - .schedule-row { + .deploy-row { grid-template-columns: 1fr; align-items: stretch; gap: 12px; } - .schedule-row-main p { + .deploy-row-main p { margin-left: 0; } - .schedule-meta { + .deploy-meta { grid-template-columns: 1fr; } - .schedule-actions { + .deploy-actions { justify-content: flex-end; } } diff --git a/apps/webui/src/app/desktop.css b/apps/webui/src/app/desktop.css index 8231f73..7ac242b 100644 --- a/apps/webui/src/app/desktop.css +++ b/apps/webui/src/app/desktop.css @@ -1539,8 +1539,8 @@ select.provision-dialog-input { animation: spin 1s linear infinite; } -/* ===== Schedule Panel ===== */ -.schedule-wrapper { +/* ===== Deployment Panel ===== */ +.deploy-wrapper { position: relative; } @@ -1571,14 +1571,14 @@ select.provision-dialog-input { background: rgba(255, 255, 255, 0.5); } -.schedule-panel-backdrop { +.deploy-panel-backdrop { position: fixed; inset: 0; z-index: 998; background: transparent; } -.schedule-panel { +.deploy-panel { position: absolute; z-index: 999; top: calc(100% + 8px); @@ -1598,13 +1598,13 @@ select.provision-dialog-input { transform 0.18s ease; } -.schedule-panel.open { +.deploy-panel.open { opacity: 1; pointer-events: auto; transform: translateY(0); } -.schedule-panel-header { +.deploy-panel-header { display: flex; align-items: center; gap: 8px; @@ -1615,13 +1615,13 @@ select.provision-dialog-input { border-bottom: 1px solid var(--line); } -.schedule-panel-options { +.deploy-panel-options { display: flex; flex-direction: column; padding: 8px; } -.schedule-option { +.deploy-option { display: flex; align-items: center; gap: 10px; @@ -1636,25 +1636,25 @@ select.provision-dialog-input { transition: background 0.12s; } -.schedule-option:hover { +.deploy-option:hover { background: var(--soft); } -.schedule-option.selected { +.deploy-option.selected { background: var(--cta); color: var(--cta-fg); } -.schedule-option.selected:hover { +.deploy-option.selected:hover { opacity: 0.9; } -.schedule-panel-footer { +.deploy-panel-footer { padding: 10px 16px 14px; border-top: 1px solid var(--line); } -.schedule-footer-hint { +.deploy-footer-hint { font-size: 12px; color: var(--muted-2); line-height: 1.4; @@ -2640,13 +2640,13 @@ select.provision-dialog-input { } /* BottomBar - 定时面板向上弹出 */ -.bottom-bar-expanded .schedule-panel { +.bottom-bar-expanded .deploy-panel { top: auto; bottom: calc(100% + 8px); transform: translateY(-6px); } -.bottom-bar-expanded .schedule-panel.open { +.bottom-bar-expanded .deploy-panel.open { transform: translateY(0); } diff --git a/apps/webui/src/app/mobile.css b/apps/webui/src/app/mobile.css index 58ccf53..5ff7e8d 100644 --- a/apps/webui/src/app/mobile.css +++ b/apps/webui/src/app/mobile.css @@ -431,7 +431,7 @@ border-radius: 10px; } - /* Schedule panel: active state shows text on mobile */ + /* Deployment panel: active state shows text on mobile */ .tool-buttons .pill-btn.active { width: auto; padding: 0 10px; @@ -448,8 +448,8 @@ display: none !important; } - /* Schedule panel: bottom sheet on mobile */ - .schedule-panel { + /* Deployment panel: bottom sheet on mobile */ + .deploy-panel { position: fixed; bottom: 0; left: 0; @@ -465,40 +465,40 @@ z-index: 9999; } - .schedule-panel.open { + .deploy-panel.open { transform: translateX(0) translateY(0); } - .schedule-panel-backdrop { + .deploy-panel-backdrop { background: rgba(0, 0, 0, 0.4); z-index: 9998; } - .schedule-panel-header { + .deploy-panel-header { padding: 20px 20px 14px; font-size: 15px; } - .schedule-panel-options { + .deploy-panel-options { padding: 10px 12px; } - .schedule-option { + .deploy-option { padding: 14px 12px; font-size: 15px; border-bottom: 1px solid var(--line); border-radius: 0; } - .schedule-option:last-child { + .deploy-option:last-child { border-bottom: none; } - .schedule-panel-footer { + .deploy-panel-footer { padding: 14px 20px 24px; } - .schedule-footer-hint { + .deploy-footer-hint { font-size: 13px; } diff --git a/apps/webui/src/components/BottomBar.tsx b/apps/webui/src/components/BottomBar.tsx index 5f3acab..d67e945 100644 --- a/apps/webui/src/components/BottomBar.tsx +++ b/apps/webui/src/components/BottomBar.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useImperativeHandle, useReducer, useRef, useSta import { usePromptEditor } from "@/components/prompt-editor/PromptEditorProvider"; import suggestions from "@/data/suggestions.json"; import type { UiModel } from "@/lib/domain/model-api"; -import type { SchedulePreset } from "@/lib/schedule"; +import type { TopBarView } from "@/lib/topbar-route"; import { AttachFilesButton, ComposerFeeNotice, @@ -12,8 +12,8 @@ import { SelectedFilesStrip, useFilePickerModal, } from "./ComposerInputShared"; +import DeploymentButton from "./DeploymentButton"; import ModelSelector from "./ModelSelector"; -import ScheduleButton from "./ScheduleButton"; import { useSubmitTask } from "./useSubmitTask"; export interface BottomBarHandle { @@ -30,8 +30,8 @@ interface BottomBarProps { composerRef: React.RefObject; onVisibilityChange: (visible: boolean) => void; onMakeSame?: (input: { prompt: string; agentId?: string }) => void; + onNavigate?: (view: TopBarView) => void; canSubmit?: boolean; - onOpenSchedule?: (preset?: SchedulePreset) => void; ref?: React.Ref; } @@ -63,7 +63,7 @@ export default function BottomBar({ onModelChange, composerRef, onVisibilityChange, - onOpenSchedule, + onNavigate, ref, canSubmit = true, }: BottomBarProps) { @@ -216,7 +216,7 @@ export default function BottomBar({
- +
diff --git a/apps/webui/src/components/Composer.tsx b/apps/webui/src/components/Composer.tsx index f7fe485..ab78a26 100644 --- a/apps/webui/src/components/Composer.tsx +++ b/apps/webui/src/components/Composer.tsx @@ -2,7 +2,7 @@ import { useEffect, useImperativeHandle, useRef, useState } from "react"; import { usePromptEditor } from "@/components/prompt-editor/PromptEditorProvider"; import type { UiModel } from "@/lib/domain/model-api"; import type { RoleCard } from "@/lib/playbooks/types"; -import type { SchedulePreset } from "@/lib/schedule"; +import type { TopBarView } from "@/lib/topbar-route"; import { AttachFilesButton, ComposerFeeNotice, @@ -11,8 +11,8 @@ import { SelectedFilesStrip, useFilePickerModal, } from "./ComposerInputShared"; +import DeploymentButton from "./DeploymentButton"; import ModelSelector from "./ModelSelector"; -import ScheduleButton from "./ScheduleButton"; import TaskBox from "./TaskBox"; import TaskRuntime from "./TaskRuntime"; import { useSubmitTask } from "./useSubmitTask"; @@ -32,7 +32,7 @@ interface ComposerProps { models: UiModel[]; onModelChange: (modelId: string) => void; onMakeSame?: (input: { prompt: string; agentId?: string }) => void; - onOpenSchedule?: (preset?: SchedulePreset) => void; + onNavigate?: (view: TopBarView) => void; canSubmit?: boolean; ref?: React.Ref; } @@ -47,7 +47,7 @@ export default function Composer({ models, onModelChange, onMakeSame, - onOpenSchedule, + onNavigate, canSubmit = true, ref, }: ComposerProps) { @@ -140,7 +140,7 @@ export default function Composer({
- +
diff --git a/apps/webui/src/components/DeploymentButton.tsx b/apps/webui/src/components/DeploymentButton.tsx new file mode 100644 index 0000000..8026dd5 --- /dev/null +++ b/apps/webui/src/components/DeploymentButton.tsx @@ -0,0 +1,184 @@ +import { CalendarClock, CalendarDays, Check, Repeat2 } from "lucide-react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createApiDeployment } from "@/lib/api/client"; +import { type DeploymentPreset, deploymentPresetValue, toCron } from "@/lib/deployment"; +import type { TopBarView } from "@/lib/topbar-route"; + +const presets = [ + { label: "每天 9:00", icon: Repeat2, value: "daily" }, + { label: "工作日 9:00", icon: CalendarDays, value: "weekdays" }, + { label: "每周一 9:00", icon: CalendarDays, value: "weekly" }, +] satisfies Array<{ label: string; icon: typeof CalendarDays; value: DeploymentPreset }>; + +interface DeploymentButtonProps { + /** Current prompt text from the composer input */ + prompt: string; + /** Current active agent slug */ + agentId: string; + /** Navigate to a top-bar view (used to jump to deployments after creation) */ + onNavigate?: (view: TopBarView) => void; +} + +/** Derive a short name from the prompt (first line, capped at 20 chars). */ +function autoName(prompt: string): string { + const first = prompt.trim().split("\n")[0] ?? ""; + return first.length > 20 ? `${first.slice(0, 20)}…` : first; +} + +export default function DeploymentButton({ prompt, agentId, onNavigate }: DeploymentButtonProps) { + const uid = useId(); + const [open, setOpen] = useState(false); + const [created, setCreated] = useState(false); + const wrapRef = useRef(null); + + const [time, setTime] = useState(() => deploymentPresetValue("daily").time); + const [repeat, setRepeat] = useState(() => deploymentPresetValue("daily").repeat); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleOpen = useCallback(() => { + const value = deploymentPresetValue("daily"); + setTime(value.time); + setRepeat(value.repeat); + setMessage(null); + setCreated(false); + setOpen(true); + }, []); + + const applyPreset = useCallback((preset: DeploymentPreset) => { + const value = deploymentPresetValue(preset); + setTime(value.time); + setRepeat(value.repeat); + }, []); + + const handleCreate = useCallback(async () => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt || !agentId) { + setMessage("请先在输入框中输入任务内容"); + return; + } + setBusy(true); + setMessage(null); + try { + const expression = toCron(time, repeat); + const result = await createApiDeployment({ + body: { + name: autoName(trimmedPrompt), + playbookId: agentId, + prompt: trimmedPrompt, + expression, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai", + }, + }); + if (result.error) { + setMessage(result.error.error.message ?? "创建失败"); + return; + } + setCreated(true); + } catch (error) { + setMessage((error as Error).message ?? "创建失败"); + } finally { + setBusy(false); + } + }, [prompt, agentId, time, repeat]); + + const timeId = `${uid}-time`; + const repeatId = `${uid}-repeat`; + + return ( +
+ + {open && ( +
+ {created ? ( +
+ + 定时任务已创建 + +
+ ) : ( + <> +
+
+ {presets.map((preset) => { + const Icon = preset.icon; + return ( + + ); + })} +
+ +
+
+ + setTime(e.target.value)} /> +
+
+ + +
+
+
+ + {message &&

{message}

} + +
+ +
+ + )} +
+ )} +
+ ); +} diff --git a/apps/webui/src/components/DeploymentCenter.tsx b/apps/webui/src/components/DeploymentCenter.tsx new file mode 100644 index 0000000..a963734 --- /dev/null +++ b/apps/webui/src/components/DeploymentCenter.tsx @@ -0,0 +1,145 @@ +import { CheckCircle2, PauseCircle, Play, PlayCircle, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { + deleteApiDeployment, + listApiDeployments, + type ManagedDeployment, + runApiDeployment, + setApiDeploymentPaused, +} from "@/lib/api/client"; +import { cronLabel } from "@/lib/deployment"; +import { getRoleCards } from "@/lib/playbooks"; +import type { RoleCard } from "@/lib/playbooks/types"; + +export default function DeploymentCenter() { + const [items, setItems] = useState([]); + const [roles, setRoles] = useState([]); + const [busy, setBusy] = useState("load"); + const [message, setMessage] = useState(null); + + const reload = useCallback(async () => { + setBusy("load"); + const result = await listApiDeployments(); + setBusy(null); + if (result.error) return setMessage(result.error.error.message ?? "加载定时任务失败"); + setItems(result.data?.deployments ?? []); + }, []); + + useEffect(() => { + void reload(); + void getRoleCards().then(setRoles); + }, [reload]); + + async function mutate( + id: string, + operation: () => Promise<{ error?: { error: { message?: string } } }>, + success: string, + ) { + setBusy(id); + setMessage(null); + try { + const result = await operation(); + if (result.error) return setMessage(result.error.error.message ?? "操作失败"); + setMessage(success); + await reload(); + } catch (error) { + setMessage((error as Error).message ?? "操作失败"); + } finally { + setBusy(null); + } + } + + return ( +
+
+
+

定时任务管理

+

定时

+

+ 查看和管理已创建的定时任务。要创建新定时任务,请在输入框中输入任务内容后点击「定时」按钮。 +

+
+
+ {message && ( +

+ {message} +

+ )} +
+
+

已创建

+ {items.length} 个计划 +
+
+ {busy === "load" && items.length === 0 &&

正在从服务端加载…

} + {busy !== "load" && items.length === 0 && ( +

暂无计划。在输入框中输入任务内容后,点击「定时」按钮即可创建。

+ )} + {items.map((item) => { + const paused = item.status.toLowerCase().includes("pause"); + return ( +
+
+
+ {paused ? : } +

{item.name}

+
+

{item.prompt}

+
+
+ {roles.find((r) => r.slug === item.playbookId)?.name ?? item.playbookId} + {cronLabel(item.schedule.expression)} + + {item.provider} · {item.status} + +
+
+ + + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/components/ScheduleButton.tsx b/apps/webui/src/components/ScheduleButton.tsx deleted file mode 100644 index 6c835ef..0000000 --- a/apps/webui/src/components/ScheduleButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { CalendarClock, CalendarDays, Repeat } from "lucide-react"; -import { useState } from "react"; -import type { SchedulePreset } from "@/lib/schedule"; - -interface ScheduleButtonProps { - onOpenSchedule?: (preset?: SchedulePreset) => void; -} - -const quickOptions = [ - { icon: Repeat, label: "每天 9:00", value: "daily" }, - { icon: CalendarDays, label: "工作日 9:00", value: "weekdays" }, - { icon: CalendarClock, label: "每周一 9:00", value: "weekly" }, -] satisfies Array<{ icon: typeof CalendarClock; label: string; value: SchedulePreset }>; - -export default function ScheduleButton({ onOpenSchedule }: ScheduleButtonProps) { - const [open, setOpen] = useState(false); - - const chooseQuickOption = (preset: SchedulePreset) => { - setOpen(false); - onOpenSchedule?.(preset); - }; - - return ( -
- - {open ? ( -
- -
- {quickOptions.map((option) => { - const Icon = option.icon; - return ( - - ); - })} -
- ) : null} -
- ); -} diff --git a/apps/webui/src/components/ScheduleCenter.tsx b/apps/webui/src/components/ScheduleCenter.tsx deleted file mode 100644 index 93b53ac..0000000 --- a/apps/webui/src/components/ScheduleCenter.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { CalendarDays, CheckCircle2, Clock3, PauseCircle, PlayCircle, Plus, Repeat2, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - createApiDeployment, - deleteApiDeployment, - listApiDeployments, - type ManagedDeployment, - runApiDeployment, - setApiDeploymentPaused, -} from "@/lib/api/client"; -import { getRoleCards } from "@/lib/playbooks"; -import type { RoleCard } from "@/lib/playbooks/types"; -import { cronLabel, type SchedulePreset, schedulePresetValue, toCron } from "@/lib/schedule"; - -const presets = [ - { label: "每天 9:00", icon: Repeat2, value: "daily" }, - { label: "工作日 9:00", icon: CalendarDays, value: "weekdays" }, - { label: "每周一 9:00", icon: CalendarDays, value: "weekly" }, -] satisfies Array<{ label: string; icon: typeof CalendarDays; value: SchedulePreset }>; - -interface ScheduleCenterProps { - draft?: { key: number; preset?: SchedulePreset; prompt: string; playbookId: string }; -} - -export default function ScheduleCenter({ draft }: ScheduleCenterProps) { - const defaultSchedule = useMemo(() => schedulePresetValue("daily"), []); - const [items, setItems] = useState([]); - const [roles, setRoles] = useState([]); - const [name, setName] = useState(""); - const [prompt, setPrompt] = useState(""); - const [playbookId, setPlaybookId] = useState(""); - const [time, setTime] = useState(defaultSchedule.time); - const [repeat, setRepeat] = useState(defaultSchedule.repeat); - const [busy, setBusy] = useState("load"); - const [message, setMessage] = useState(null); - - const reload = useCallback(async () => { - setBusy("load"); - const result = await listApiDeployments(); - setBusy(null); - if (result.error) return setMessage(result.error.error.message ?? "加载定时任务失败"); - setItems(result.data?.deployments ?? []); - }, []); - - useEffect(() => { - void reload(); - void getRoleCards().then((cards) => { - setRoles(cards); - setPlaybookId((current) => current || cards[0]?.slug || ""); - }); - }, [reload]); - - useEffect(() => { - if (!draft?.key) return; - setPrompt(draft.prompt); - if (draft.playbookId) setPlaybookId(draft.playbookId); - if (draft.preset) { - const value = schedulePresetValue(draft.preset); - setTime(value.time); - setRepeat(value.repeat); - } - }, [draft]); - - async function create() { - if (!name.trim() || !prompt.trim() || !playbookId) return setMessage("请填写名称、任务内容并选择执行角色"); - setBusy("create"); - setMessage(null); - let expression: string; - try { - expression = toCron(time, repeat); - } catch (error) { - setBusy(null); - return setMessage((error as Error).message); - } - const result = await createApiDeployment({ - body: { - name: name.trim(), - playbookId, - prompt: prompt.trim(), - expression, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai", - }, - }); - setBusy(null); - if (result.error) return setMessage(result.error.error.message ?? "创建失败"); - setName(""); - setPrompt(""); - setMessage("已创建并同步到真实 deployment 服务"); - await reload(); - } - - async function mutate( - id: string, - operation: () => Promise<{ error?: { error: { message?: string } } }>, - success: string, - ) { - setBusy(id); - setMessage(null); - const result = await operation(); - setBusy(null); - if (result.error) return setMessage(result.error.error.message ?? "操作失败"); - setMessage(success); - await reload(); - } - - return ( -
-
-
-

真实服务自动执行

-

定时

-

通过 OpenCMA server 创建 Qoder / Claude 原生 Deployment,到点自动运行。

-
- -
- {message && ( -

- {message} -

- )} -
-
-
- - setName(e.target.value)} - placeholder="每日增长数据复盘" - /> -
-
- - -
-
-
- -