diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index e5ef45eeb..6a5e366f7 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -40,6 +40,12 @@ import type { WorkspaceList, } from "./desktop-types"; import type { BrowserPanelTab } from "./desktop-types"; +import type { + ScheduledTask, + ScheduledTaskCreateInput, + ScheduledTaskLogEntry, + ScheduledTaskUpdatePatch, +} from "@/react-app/domains/session/scheduled-tasks/scheduled-task"; export const LOCAL_IMAGE_FILE_EXTENSIONS = ["avif", "bmp", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"]; export const LOCAL_IMAGE_FILE_FILTERS = [{ name: "图片文件", extensions: LOCAL_IMAGE_FILE_EXTENSIONS }]; @@ -53,6 +59,17 @@ export type BrowserProxyState = { proxy: { rules: string; authenticated: boolean } | null; }; +export type LanPreviewState = { + enabled: boolean; + port: number; + addresses: string[]; + code: string | null; + codeExpiresAt: number; + sessionCount: number; + pendingChallengeCount?: number; + error?: string; +}; + // --------------------------------------------------------------------------- // Electron bridge surface // --------------------------------------------------------------------------- @@ -154,13 +171,51 @@ declare global { onPanelClosed?: (callback: () => void) => () => void; }; terminal?: { - create?: (options: { cwd: string; cols: number; rows: number }) => Promise<{ terminalId: string }>; + create?: (options: { + cwd: string; + cols: number; + rows: number; + /** Optional argv for the shell. When present the shell runs this + command instead of opening an interactive prompt, e.g. + `["ssh", "user@host"]` for the ops panel. */ + command?: string[]; + /** Optional explicit shell/executable path. */ + shell?: string; + }) => Promise<{ terminalId: string }>; write?: (terminalId: string, data: string) => Promise; resize?: (terminalId: string, cols: number, rows: number) => Promise; kill?: (terminalId: string) => Promise; onData?: (callback: (payload: { terminalId: string; data: string }) => void) => () => void; onExit?: (callback: (payload: { terminalId: string; exitCode: number | null; signal?: number }) => void) => () => void; }; + ssh?: { + listHosts?: () => Promise<{ hosts: string[]; configPath: string }>; + }; + git?: { + graph?: (options: { cwd: string; maxCommits?: number }) => Promise< + | { ok: true; repoRoot: string; count: number; totalCount: number | null; truncated: boolean; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] } + | { ok: false; isRepo: boolean; error: string } + >; + }; + scheduledTasks?: { + list?: () => Promise; + create?: (input: ScheduledTaskCreateInput) => Promise; + update?: (id: string, patch: ScheduledTaskUpdatePatch) => Promise; + setEnabled?: (id: string, enabled: boolean) => Promise; + remove?: (id: string) => Promise; + runNow?: (id: string) => Promise; + logs?: (id: string) => Promise; + preview?: (cron: string) => Promise<{ valid: boolean; nextRunAt: number | null }>; + onChanged?: (callback: (payload: { type: string; taskId?: string }) => void) => () => void; + }; + lanPreview?: { + getState?: () => Promise; + setEnabled?: (enabled: boolean) => Promise; + regenerateCode?: () => Promise; + disconnectAll?: () => Promise; + pushToIm?: (options: { mcpUrl: string }) => Promise<{ ok: boolean; tool?: string; error?: string }>; + onStateChanged?: (callback: (state: LanPreviewState) => void) => () => void; + }; hyperframes?: { start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>; stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>; @@ -576,6 +631,9 @@ const { updaterEnvironment, readOpencodeConfig, writeOpencodeConfig, + routerGatewayGetConfig, + routerGatewayWriteConfig, + routerGatewayStatus, resetiPolloWorkState, resetOpencodeCache, opencodeMcpAuth, @@ -644,6 +702,9 @@ export { updaterEnvironment, readOpencodeConfig, writeOpencodeConfig, + routerGatewayGetConfig, + routerGatewayWriteConfig, + routerGatewayStatus, resetiPolloWorkState, resetOpencodeCache, opencodeMcpAuth, diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 70a2e45e6..d88ef3964 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -193,6 +193,7 @@ export const SETTINGS_TAB_VALUES = [ "updates", "recovery", "debug", + "remote-preview", ] as const; export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number]; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 65874f7ba..0849c9786 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -752,6 +752,7 @@ export default { "find.no_matches": "No matches", "find.previous_match": "Previous match", "find.previous_match_hint": "Previous match (⇧↵)", + "git.title": "Git", "help.all_categories": "All categories", "help.back": "Back to Work", "help.clear_search": "Clear search", @@ -855,7 +856,7 @@ export default { "mcp.enable_app": "Enable", "mcp.env_modal_cancel": "Cancel", "mcp.env_modal_desc": "These credentials are stored in this device's encrypted env store and never uploaded. Connection continues automatically after saving.", -"mcp.env_modal_open_console": "Open platform console (create app)", + "mcp.env_modal_open_console": "Open platform console (create app)", "mcp.env_modal_placeholder": "Paste the value", "mcp.env_modal_save": "Save & connect", "mcp.env_modal_save_failed": "Save failed. Check the values and try again.", @@ -878,7 +879,7 @@ export default { "mcp.logout_modal_title": "Log out of this app?", "mcp.logout_success": "Logged out of {server}.", "mcp.logout_working": "Logging out...", -"mcp.missing_env": "Set these environment variables first: {vars}. Add them in Settings → Environment Variables, then retry.", + "mcp.missing_env": "Set these environment variables first: {vars}. Add them in Settings → Environment Variables, then retry.", "mcp.name_required": "Enter a server name.", "mcp.no_apps_hint": "Connect one above to get started.", "mcp.no_apps_yet": "No apps connected yet", @@ -1208,6 +1209,7 @@ export default { "notifications.view": "View", "onboarding.authorize_folder": "Authorize folder", "onboarding.installing_resources": "Installing required resources\u2026", + "ops.title": "Ops", "plugin_library.add": "Add", "plugin_library.category.ai-agents": "AI Agents & Automation", "plugin_library.category.business-operations": "Business & Operations", @@ -1375,6 +1377,7 @@ export default { "remote_workspace.optional_placeholder": "Optional", "remote_workspace.worker_url": "Worker URL", "remote_workspace.worker_url_hint": "Paste the iPolloWork worker URL you want to connect to.", + "scheduled_tasks.title": "Scheduled Tasks", "session.add_provider_hint": "Add a provider to run tasks.", "session.allow_for_session": "Always allow", "session.allow_once": "Allow once", @@ -2183,6 +2186,35 @@ export default { "settings.recovery_reset_config_unavailable": "Reset config is not yet available.", "settings.recovery_section_desc": "Roll back state without quitting iPolloWork.", "settings.recovery_section_title": "Reset & recovery", + "settings.remote_preview_address_desc": "Open this address in a phone or tablet browser on the same network.", + "settings.remote_preview_address_title": "Access address", + "settings.remote_preview_alert_desc": "Lets phones and tablets on your local network view the current workbench state through a browser. Read-only: devices can watch, not control.", + "settings.remote_preview_alert_title": "Remote preview (LAN read-only)", + "settings.remote_preview_code_desc": "Valid for 10 minutes, single use", + "settings.remote_preview_code_hint": "Enter this code on the device to pair it with this workbench.", + "settings.remote_preview_code_title": "Pair code", + "settings.remote_preview_copy": "Copy", + "settings.remote_preview_desktop_desc": "Remote preview is only available in the desktop app.", + "settings.remote_preview_desktop_title": "Desktop app required", + "settings.remote_preview_disconnect_all": "Disconnect all", + "settings.remote_preview_enable_desc": "Defaults to off. Turning it on exposes a read-only snapshot to your local network.", + "settings.remote_preview_enable_title": "Enable LAN preview", + "settings.remote_preview_error_title": "Could not start remote preview", + "settings.remote_preview_im_desc": "Send the current workbench status to a DingTalk/Feishu group via its MCP endpoint.", + "settings.remote_preview_im_endpoint_label": "DingTalk MCP endpoint URL", + "settings.remote_preview_im_endpoint_placeholder": "https://… DingTalk MCP endpoint (dws mcp url get )", + "settings.remote_preview_im_failed": "Could not send the preview summary.", + "settings.remote_preview_im_hint": "Get the endpoint from `dws mcp url get `. Only a redacted status summary is sent; no session tokens.", + "settings.remote_preview_im_need_endpoint": "Enter a DingTalk MCP endpoint first.", + "settings.remote_preview_im_push": "Push summary", + "settings.remote_preview_im_sent": "Preview summary sent.", + "settings.remote_preview_im_title": "Push preview to IM", + "settings.remote_preview_im_unavailable": "IM push is only available in the desktop app.", + "settings.remote_preview_no_address": "No LAN addresses found.", + "settings.remote_preview_regenerate": "Regenerate", + "settings.remote_preview_sessions_connected": "devices connected", + "settings.remote_preview_sessions_none": "No devices paired.", + "settings.remote_preview_sessions_title": "Paired devices", "settings.removing_containers": "Removing containers...", "settings.removing_local_state": "Removing local state...", "settings.repair_cache": "Repair cache", @@ -2223,6 +2255,32 @@ export default { "settings.restarting": "Restarting…", "settings.reveal_config": "Show file", "settings.reveal_config_requires_desktop": "Only available in the desktop app", + "settings.router_gateway.add_model": "Add escalation model", + "settings.router_gateway.api_key": "API key", + "settings.router_gateway.api_key_env": "API key env var name", + "settings.router_gateway.api_key_optional": "Optional; leave empty to use the env var", + "settings.router_gateway.base_url": "Base URL", + "settings.router_gateway.config_path": "Config file: {path}", + "settings.router_gateway.context_limit": "Context limit (tokens)", + "settings.router_gateway.debug": "Debug logging", + "settings.router_gateway.description": "Local smart routing gateway: requests go to the primary model by default, and switch to an escalation model after the context is repeatedly compacted past the limit. Saving takes effect immediately.", + "settings.router_gateway.desktop_only": "Smart routing gateway config is only available on desktop.", + "settings.router_gateway.escalation_desc": "After the session context is compacted (compaction) up to the limit, subsequent requests switch to the first escalation model in the list.", + "settings.router_gateway.escalation_label": "Enable auto escalation", + "settings.router_gateway.escalation_model": "Escalation model #{index}", + "settings.router_gateway.loading": "Loading gateway config...", + "settings.router_gateway.max_compactions": "Compaction limit", + "settings.router_gateway.merge_system": "Merge system messages to front", + "settings.router_gateway.model_name": "Model name", + "settings.router_gateway.not_running": "Gateway not running", + "settings.router_gateway.primary_label": "Primary model", + "settings.router_gateway.primary_model": "Primary model: {model}", + "settings.router_gateway.refresh": "Refresh", + "settings.router_gateway.remove_model": "Remove", + "settings.router_gateway.running": "Running", + "settings.router_gateway.saved": "Routing gateway config saved", + "settings.router_gateway.saving": "Saving...", + "settings.router_gateway.title": "Smart Routing Gateway", "settings.run_sandbox_probe": "Running probe...", "settings.running_probe": "Running probe...", "settings.runtime_config.agents_plugins": "Agents / plugins", @@ -2393,6 +2451,7 @@ export default { "settings.tab_description_pet": "An always-on desktop companion: progress reminders, pending decisions, and chat.", "settings.tab_description_preferences": "Default model, reasoning, and compaction", "settings.tab_description_recovery": "Fix common issues, reset workspace settings, or clean up leftover data.", + "settings.tab_description_remote_preview": "Allow LAN devices to view this workbench (read-only).", "settings.tab_description_shell": "Branding, visibility, and shell controls", "settings.tab_description_skills": "Browse, edit, and install skills without leaving settings.", "settings.tab_description_updates": "Keep the app current with quiet background checks and install controls.", @@ -2403,6 +2462,7 @@ export default { "settings.tab_pet": "Desktop Pet", "settings.tab_preferences": "Preferences", "settings.tab_recovery": "Recovery", + "settings.tab_remote_preview": "Remote Preview", "settings.tab_shell": "Customization", "settings.tab_skills": "Skills", "settings.tab_updates": "Updates", @@ -3074,4 +3134,4 @@ export default { "workspace_list.test_connection": "Test connection", "workspace_list.unavailable": "Unavailable", "workspace_list.workspace_fallback": "Workspace", -} as const; \ No newline at end of file +} as const; diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 4e19bebb1..97e21efc6 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -766,6 +766,7 @@ export default { "find.no_matches": "没有匹配项", "find.previous_match": "上一个匹配项", "find.previous_match_hint": "上一个匹配项(⇧↵)", + "git.title": "Git", "help.all_categories": "全部分类", "help.back": "返回 Work", "help.clear_search": "清除搜索", @@ -869,7 +870,7 @@ export default { "mcp.enable_app": "启用", "mcp.env_modal_cancel": "取消", "mcp.env_modal_desc": "以下凭据将保存到本机的环境变量存储(加密保存在本设备,不会上传)。填好后自动完成连接。", -"mcp.env_modal_open_console": "打开开放平台(创建应用)", + "mcp.env_modal_open_console": "打开开放平台(创建应用)", "mcp.env_modal_placeholder": "粘贴对应的值", "mcp.env_modal_save": "保存并连接", "mcp.env_modal_save_failed": "保存失败,请检查值是否正确后重试。", @@ -892,7 +893,7 @@ export default { "mcp.logout_modal_title": "退出登录?", "mcp.logout_success": "{server}已退出登录。", "mcp.logout_working": "正在退出...", -"mcp.missing_env": "连接前请先配置环境变量:{vars}。到 设置 → 环境变量 里填好后重试。", + "mcp.missing_env": "连接前请先配置环境变量:{vars}。到 设置 → 环境变量 里填好后重试。", "mcp.name_required": "请输入服务器名称。", "mcp.no_apps_hint": "连接上方的应用开始使用。", "mcp.no_apps_yet": "暂无已连接的应用", @@ -1226,6 +1227,7 @@ export default { "notifications.view": "查看", "onboarding.authorize_folder": "授权文件夹", "onboarding.installing_resources": "正在安装相关资源中\u2026", + "ops.title": "运维", "plugin_library.add": "添加", "plugin_library.category.ai-agents": "AI Agent 与自动化", "plugin_library.category.business-operations": "商业与运营", @@ -1393,6 +1395,7 @@ export default { "remote_workspace.optional_placeholder": "可选", "remote_workspace.worker_url": "工作器 URL", "remote_workspace.worker_url_hint": "粘贴要连接的 iPolloWork 工作器 URL。", + "scheduled_tasks.title": "定时任务", "session.add_provider_hint": "添加提供商即可运行任务。", "session.allow_for_session": "始终允许", "session.allow_once": "允许一次", @@ -2201,6 +2204,35 @@ export default { "settings.recovery_reset_config_unavailable": "重置配置暂不可用。", "settings.recovery_section_desc": "无需退出 iPolloWork 即可回退状态。", "settings.recovery_section_title": "重置与恢复", + "settings.remote_preview_address_desc": "在手机或平板浏览器中打开该地址(需同一网络)。", + "settings.remote_preview_address_title": "访问地址", + "settings.remote_preview_alert_desc": "让同一局域网内的手机、平板通过浏览器查看当前工作台状态。只读:设备只能观看,不能操作。", + "settings.remote_preview_alert_title": "远程预览(局域网只读)", + "settings.remote_preview_code_desc": "10 分钟有效,单次使用", + "settings.remote_preview_code_hint": "在设备上输入此配对码即可与工作台配对。", + "settings.remote_preview_code_title": "配对码", + "settings.remote_preview_copy": "复制", + "settings.remote_preview_desktop_desc": "远程预览仅在桌面应用内可用。", + "settings.remote_preview_desktop_title": "需要桌面应用", + "settings.remote_preview_disconnect_all": "断开全部", + "settings.remote_preview_enable_desc": "默认关闭。开启后会向局域网暴露只读快照。", + "settings.remote_preview_enable_title": "启用局域网预览", + "settings.remote_preview_error_title": "远程预览启动失败", + "settings.remote_preview_im_desc": "通过 MCP 端点将当前工作台状态发送到钉钉/飞书群。", + "settings.remote_preview_im_endpoint_label": "钉钉 MCP 端点 URL", + "settings.remote_preview_im_endpoint_placeholder": "https://… 钉钉 MCP 端点(dws mcp url get )", + "settings.remote_preview_im_failed": "发送预览摘要失败。", + "settings.remote_preview_im_hint": "用 `dws mcp url get ` 获取端点。仅发送脱敏后的状态摘要,不包含任何会话令牌。", + "settings.remote_preview_im_need_endpoint": "请先输入钉钉 MCP 端点。", + "settings.remote_preview_im_push": "推送摘要", + "settings.remote_preview_im_sent": "预览摘要已发送。", + "settings.remote_preview_im_title": "推送预览到 IM", + "settings.remote_preview_im_unavailable": "IM 推送仅在桌面应用中可用。", + "settings.remote_preview_no_address": "未找到局域网地址。", + "settings.remote_preview_regenerate": "重新生成", + "settings.remote_preview_sessions_connected": "台设备已连接", + "settings.remote_preview_sessions_none": "暂无已配对设备。", + "settings.remote_preview_sessions_title": "已配对设备", "settings.removing_containers": "正在移除容器…", "settings.removing_local_state": "正在移除本地状态…", "settings.repair_cache": "修复缓存", @@ -2241,6 +2273,32 @@ export default { "settings.restarting": "正在重启…", "settings.reveal_config": "显示配置", "settings.reveal_config_requires_desktop": "显示配置需要桌面应用", + "settings.router_gateway.add_model": "添加升级模型", + "settings.router_gateway.api_key": "API 密钥", + "settings.router_gateway.api_key_env": "API 密钥环境变量名", + "settings.router_gateway.api_key_optional": "可选,留空则用环境变量", + "settings.router_gateway.base_url": "Base URL", + "settings.router_gateway.config_path": "配置文件:{path}", + "settings.router_gateway.context_limit": "上下文上限 (tokens)", + "settings.router_gateway.debug": "调试日志", + "settings.router_gateway.description": "本地智能路由网关:默认请求走主模型,上下文反复压缩超过上限后自动切换到升级模型。修改后保存即热生效。", + "settings.router_gateway.desktop_only": "智能路由网关配置仅在桌面端可用。", + "settings.router_gateway.escalation_desc": "当会话上下文反复压缩(compaction)达到上限次数后,后续请求自动切换到列表中的第一个升级模型。", + "settings.router_gateway.escalation_label": "启用自动升级", + "settings.router_gateway.escalation_model": "升级模型 #{index}", + "settings.router_gateway.loading": "正在读取网关配置…", + "settings.router_gateway.max_compactions": "压缩上限", + "settings.router_gateway.merge_system": "合并 system 到开头", + "settings.router_gateway.model_name": "模型名", + "settings.router_gateway.not_running": "网关未运行", + "settings.router_gateway.primary_label": "主模型", + "settings.router_gateway.primary_model": "主模型:{model}", + "settings.router_gateway.refresh": "刷新", + "settings.router_gateway.remove_model": "移除", + "settings.router_gateway.running": "运行中", + "settings.router_gateway.saved": "路由网关配置已保存", + "settings.router_gateway.saving": "保存中…", + "settings.router_gateway.title": "智能路由网关", "settings.run_sandbox_probe": "正在运行探测…", "settings.running_probe": "正在运行探测…", "settings.runtime_config.agents_plugins": "智能体 / 插件", @@ -2411,6 +2469,7 @@ export default { "settings.tab_description_pet": "常驻桌面的数字助手:任务提醒、待决策通知与陪伴对话。", "settings.tab_description_preferences": "默认模型、推理和上下文压缩。", "settings.tab_description_recovery": "修复迁移状态、重置工作区默认值和恢复本地设置。", + "settings.tab_description_remote_preview": "允许局域网设备查看此工作台(只读)。", "settings.tab_description_shell": "品牌、可见性和应用外壳控制项。", "settings.tab_description_skills": "在设置中浏览、编辑和安装skills。", "settings.tab_description_updates": "通过静默后台检查和安装控制保持应用为最新版本。", @@ -2421,6 +2480,7 @@ export default { "settings.tab_pet": "桌面助手", "settings.tab_preferences": "偏好设置", "settings.tab_recovery": "恢复", + "settings.tab_remote_preview": "远程预览", "settings.tab_shell": "自定义", "settings.tab_skills": "Skills(技能)", "settings.tab_updates": "更新", @@ -3093,4 +3153,4 @@ export default { "workspace_list.test_connection": "测试连接", "workspace_list.unavailable": "不可用", "workspace_list.workspace_fallback": "工作区", -} as const; \ No newline at end of file +} as const; diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 394479d4f..9592904c7 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -122,6 +122,9 @@ import { shouldRefreshTemplateCatalogOnOpen } from "../templates/template-market import { savePromptTemplate } from "@/react-app/domains/session/templates/prompt-template-store"; import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; +import { OpsPanel } from "../ops/ops-panel"; +import { GitPanel } from "../git/git-panel"; +import { ScheduledTasksPanel } from "../scheduled-tasks/scheduled-tasks-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; @@ -1235,7 +1238,7 @@ export function SessionPage(props: SessionPageProps) { const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); const [removeGroupOpen, setRemoveGroupOpen] = useState(false); const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); - const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | null>(null); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | "scheduled-tasks" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2080,6 +2083,18 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("extensions"); }, [setCurrentSidePanel]); + const openOpsRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("ops"); + }, [setCurrentSidePanel]); + const openGitRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("git"); + }, [setCurrentSidePanel]); + const openScheduledTasksRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("scheduled-tasks"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2305,7 +2320,7 @@ export function SessionPage(props: SessionPageProps) { (showWorkspaceSetupEmptyState || (props.selectedSessionId && !selectedSessionIsDefaultTitle)), ); const showMainHeaderMenu = showHeaderMenu && showMainHeaderTitle; - const mainHeaderHidden = mainWorkspaceView === "extensions" || (showNewConversationChrome && !sidebarVisuallyCollapsed); + const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || mainWorkspaceView === "git" || mainWorkspaceView === "scheduled-tasks" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2422,12 +2437,15 @@ export function SessionPage(props: SessionPageProps) { name: denAuth.user?.name ?? null, email: denAuth.user?.email ?? null, }} - activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : null} + activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : mainWorkspaceView === "git" ? "git" : mainWorkspaceView === "scheduled-tasks" ? "scheduled-tasks" : null} onOpenAccount={openCloudAccount} onOpenSettings={props.onOpenSettings} onOpenHelp={props.onOpenHelp} onOpenTemplateMarket={() => setTemplateMarketOpen(true)} onOpenExtensions={openExtensionsRailPane} + onOpenOps={openOpsRailPane} + onOpenGit={openGitRailPane} + onOpenScheduledTasks={openScheduledTasksRailPane} onSignIn={openCloudSignIn} onOpenSessionSearch={props.sidebar.onOpenSessionSearch ? handleSidebarOpenSessionSearch : undefined} onStartResize={startLeftSidebarResize} @@ -2620,6 +2638,18 @@ export function SessionPage(props: SessionPageProps) {
{props.settingsSlot}
+ ) : mainWorkspaceView === "ops" ? ( +
+ setMainWorkspaceView(null)} /> +
+ ) : mainWorkspaceView === "git" ? ( +
+ setMainWorkspaceView(null)} /> +
+ ) : mainWorkspaceView === "scheduled-tasks" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
diff --git a/apps/app/src/react-app/domains/session/git/git-panel.tsx b/apps/app/src/react-app/domains/session/git/git-panel.tsx new file mode 100644 index 000000000..c57aa9e03 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/git-panel.tsx @@ -0,0 +1,277 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { GitBranch, GitCommitHorizontal, Loader2, RefreshCw, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { + layoutGraph, + shortSha, + shortRef, + type GraphCommit, + type GraphRef, + type LayoutCommit, +} from "./graph-layout"; + +const LANE_WIDTH = 26; +const ROW_HEIGHT = 26; +const RADIUS = 7; +const MAX_COMMITS = 1200; + +type GraphData = { + ok: boolean; + isRepo: boolean; + error?: string; + count?: number; + totalCount?: number | null; + truncated?: boolean; + commits?: GraphCommit[]; + refs?: GraphRef[]; + headShas?: string[]; +}; + +function commitMessage(sha: string): string { + return shortSha(sha); +} + +type GitPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedSha, setSelectedSha] = useState(null); + + const fetchGraph = () => { + if (!isElectronRuntime()) { + setData({ ok: false, isRepo: false, error: "Git panel is available in the desktop app." }); + setLoading(false); + return; + } + const graph = window.__IPOLLOWORK_ELECTRON__?.git?.graph; + if (!graph) { + setData({ ok: false, isRepo: false, error: "Git bridge is unavailable." }); + setLoading(false); + return; + } + setLoading(true); + void graph({ cwd: workspaceRoot, maxCommits: MAX_COMMITS }) + .then((result) => { + setData(result as GraphData); + setSelectedSha(null); + }) + .catch((error) => { + setData({ ok: false, isRepo: false, error: error instanceof Error ? error.message : "Could not read git graph." }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceRoot]); + + const { rows, laneCount } = useMemo(() => { + if (!data?.ok || !data.commits) return { rows: [] as LayoutCommit[], laneCount: 0 }; + return layoutGraph(data.commits, data.refs ?? []); + }, [data]); + + const width = Math.max(320, laneCount * LANE_WIDTH + 24); + + const edgeLines = useMemo(() => { + if (!rows.length) return []; + const lines: { key: string; d: string; stub?: boolean }[] = []; + const rowBySha = new Map(rows.map((r) => [r.sha, r.row])); + const laneBySha = new Map(rows.map((r) => [r.sha, r.lane])); + for (const commit of rows) { + for (const parent of commit.parents) { + const childX = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const childY = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const parentRow = rowBySha.get(parent); + if (parentRow === undefined) { + // Parent lies outside the fetched window (truncated history). + // Draw a short dangling stub downward to signal the cut. + const stubEnd = childY + 12; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} L ${childX} ${stubEnd}`, + stub: true, + }); + continue; + } + const parentLane = laneBySha.get(parent); + if (parentLane === undefined) continue; + const parentX = parentLane * LANE_WIDTH + LANE_WIDTH / 2; + const parentY = parentRow * ROW_HEIGHT + ROW_HEIGHT / 2; + if (commit.lane === parentLane) { + lines.push({ key: `${commit.sha}-${parent}`, d: `M ${childX} ${childY} L ${parentX} ${parentY}` }); + } else { + const midY = childY + (parentY - childY) / 2; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} V ${midY} H ${parentX} V ${parentY}`, + }); + } + } + } + return lines; + }, [rows]); + + const selected = selectedSha ? rows.find((r) => r.sha === selectedSha) : null; + + return ( +
+
+
+ + Git 图谱 + 泳道 +
+
+ + {onClose ? ( + + ) : null} +
+
+ + {data?.ok && data.truncated && data.totalCount !== undefined && data.totalCount !== null ? ( +
+ + + 历史已截断:显示前 {data.count} 条,仓库共有 {data.totalCount} 条提交。虚线表示截断边界。 + +
+ ) : null} + +
+
+ {loading ? ( +
+ + 读取 Git 图谱… +
+ ) : !data?.ok ? ( +
+

+ {data?.isRepo === false ? "当前目录不是 Git 仓库。" : data?.error ?? "无法读取 Git 图谱。"} +

+ {data?.error ?

{data.error}

: null} +
+ ) : rows.length === 0 ? ( +

没有可显示的提交。

+ ) : ( +
+ + {edgeLines.map((line) => ( + + ))} + {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + const fill = isHead ? "var(--color-ring)" : isSelected ? "var(--color-primary)" : "var(--color-muted-foreground)"; + return ( + setSelectedSha(isSelected ? null : commit.sha)} + style={{ cursor: "pointer" }} + > + + + + ); + })} + +
+ {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + return ( + + ); + })} +
+
+ )} +
+ + +
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/git/graph-layout.ts b/apps/app/src/react-app/domains/session/git/graph-layout.ts new file mode 100644 index 000000000..1fbae38b0 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/graph-layout.ts @@ -0,0 +1,144 @@ +/** + * Pure git-graph swimlane layout. + * + * Input is the commit DAG produced by the Electron main process + * (`git rev-list --parents --all`) plus ref → commit mapping. This module is + * deliberately framework-free so the layout algorithm can be unit-tested + * without React/DOM. + */ + +export type GraphCommit = { + sha: string; + parents: string[]; +}; + +export type GraphRef = { + sha: string; + refname: string; + head: boolean; +}; + +export type LayoutCommit = GraphCommit & { + row: number; + lane: number; + refs: GraphRef[]; +}; + +export type GraphLayout = { + rows: LayoutCommit[]; + laneCount: number; +}; + +/** + * Greedy lane assignment based on parent inheritance: + * - a commit inherits its FIRST parent's lane (linear history stays in one + * column); + * - the second+ parent of a merge opens a new lane; + * - a lane whose owner has already been placed can be reused for a fresh + * branch. + * + * Invariants: + * - every row index equals its position in the input order (0..n-1); + * - every lane index is a non-negative integer in [0, laneCount); + * - a commit's lane never changes once assigned; + * - every commit with at least one parent inherits that parent's lane unless + * the parent's lane is still owned by an unplaced commit and a second + * parent forces a fork. + */ +export function layoutGraph(commits: GraphCommit[], refs: GraphRef[] = []): GraphLayout { + const refsBySha = new Map(); + for (const ref of refs) { + const list = refsBySha.get(ref.sha) ?? []; + list.push(ref); + refsBySha.set(ref.sha, list); + } + + const laneOf = new Map(); + const laneOwner: (string | undefined)[] = []; + const placed = new Set(); + let laneCount = 0; + + const freeLaneOrNew = () => { + for (let i = 0; i < laneCount; i++) { + const owner = laneOwner[i]; + if (owner === undefined || placed.has(owner)) return i; + } + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const claimLane = (sha: string) => { + const existing = laneOf.get(sha); + if (existing !== undefined) return existing; + const lane = freeLaneOrNew(); + laneOf.set(sha, lane); + laneOwner[lane] = sha; + return lane; + }; + + // A fresh lane that never reuses a sibling's lane; used only when a commit + // forks away from a parent whose lane is already occupied by a sibling. + const claimFreshLane = () => { + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const rows: LayoutCommit[] = []; + commits.forEach((commit, row) => { + const firstParent = commit.parents[0]; + let lane: number; + const alreadyAssigned = laneOf.get(commit.sha); + if (alreadyAssigned !== undefined) { + // The commit was reserved as a parent earlier; keep its lane. + lane = alreadyAssigned; + } else if ( + firstParent !== undefined + && laneOf.has(firstParent) + && laneOwner[laneOf.get(firstParent)!] === firstParent + ) { + // Inherit the first parent's lane (linear continuation) — but only if + // the parent still occupies that lane. A sibling branch that already + // inherited it forces this branch into its own fresh lane. + lane = laneOf.get(firstParent)!; + } else if (firstParent !== undefined && laneOf.has(firstParent)) { + // Parent's lane is held by a sibling branch; fork to a fresh lane so + // the two branches remain visually distinct. + lane = claimFreshLane(); + } else { + lane = freeLaneOrNew(); + } + placed.add(commit.sha); + laneOf.set(commit.sha, lane); + laneOwner[lane] = commit.sha; + rows.push({ + ...commit, + row, + lane, + refs: refsBySha.get(commit.sha) ?? [], + }); + // Reserve lanes for parents not yet assigned. The first parent shares + // this lane (unless it already has one); second+ parents fork to new + // lanes so merges become visible. + commit.parents.forEach((parent, index) => { + if (laneOf.has(parent)) return; + if (index === 0) { + laneOf.set(parent, lane); + laneOwner[lane] = parent; + } else { + claimLane(parent); + } + }); + }); + + return { rows, laneCount }; +} + +export function shortSha(sha: string): string { + return sha.slice(0, 7); +} + +export function shortRef(refname: string): string { + return refname.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); +} diff --git a/apps/app/src/react-app/domains/session/ops/ops-panel.tsx b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx new file mode 100644 index 000000000..3f73967c1 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx @@ -0,0 +1,383 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { Loader2, Plus, RefreshCw, Server, TerminalSquare, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { normalizeSshTarget } from "./ops-utils"; + +type OpsSession = { + id: string; + label: string; + target: string; + terminalId: string | null; + exited: boolean; +}; + +type OpsTerminalProps = { + session: OpsSession; + onStatus: (sessionId: string, status: string) => void; + onExit: (sessionId: string) => void; + onRequestFocus: () => void; +}; + +function OpsTerminal({ session, onStatus, onExit, onRequestFocus }: OpsTerminalProps) { + const containerRef = useRef(null); + const terminalIdRef = useRef(null); + const terminalRef = useRef(null); + const fitRef = useRef(null); + const startedRef = useRef(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + if (!isElectronRuntime()) { + onStatus(session.id, "Terminal is available in the desktop app."); + return; + } + if (startedRef.current) return; + startedRef.current = true; + + const bridge = window.__IPOLLOWORK_ELECTRON__?.terminal; + if (!bridge?.create || !bridge.write || !bridge.resize || !bridge.kill || !bridge.onData || !bridge.onExit) { + onStatus(session.id, "Terminal bridge is unavailable."); + return; + } + const createTerminal = bridge.create; + const writeTerminal = bridge.write; + const resizeTerminal = bridge.resize; + const killTerminal = bridge.kill; + const onTerminalData = bridge.onData; + const onTerminalExit = bridge.onExit; + + let disposed = false; + const fitAddon = new FitAddon(); + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "'SFMono-Regular', 'Cascadia Code', 'Liberation Mono', Menlo, monospace", + fontSize: 12, + theme: { + background: "#0b0d12", + foreground: "#d7dde8", + cursor: "#ffffff", + selectionBackground: "#334155", + }, + }); + terminal.loadAddon(fitAddon); + terminal.open(container); + terminal.focus(); + fitAddon.fit(); + terminalRef.current = terminal; + fitRef.current = fitAddon; + + const removeDataListener = onTerminalData(({ terminalId, data }) => { + if (terminalIdRef.current !== terminalId) return; + terminal.write(data); + }); + const removeExitListener = onTerminalExit(({ terminalId }) => { + if (terminalIdRef.current !== terminalId) return; + terminalIdRef.current = null; + onExit(session.id); + }); + const inputDisposable = terminal.onData((data) => { + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void writeTerminal(terminalId, data); + }); + + const fitAndResize = () => { + fitAddon.fit(); + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void resizeTerminal(terminalId, terminal.cols, terminal.rows); + }; + const resizeObserver = new ResizeObserver(fitAndResize); + resizeObserver.observe(container); + + onStatus(session.id, `Connecting to ${session.target}…`); + void createTerminal({ + cwd: "/", + cols: terminal.cols, + rows: terminal.rows, + command: ["ssh", "-t", session.target], + }) + .then(({ terminalId }) => { + if (disposed) { + void killTerminal(terminalId); + return; + } + terminalIdRef.current = terminalId; + onStatus(session.id, session.target); + fitAndResize(); + }) + .catch((error) => { + onStatus(session.id, error instanceof Error ? error.message : "Could not start SSH session."); + }); + + return () => { + disposed = true; + resizeObserver.disconnect(); + inputDisposable.dispose(); + removeDataListener(); + removeExitListener(); + const terminalId = terminalIdRef.current; + terminalIdRef.current = null; + if (terminalId) void killTerminal(terminalId); + terminal.dispose(); + terminalRef.current = null; + fitRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.id]); + + return ( +
+
+
+ ); +} + +type OpsPanelProps = { + onClose?: () => void; +}; + +export function OpsPanel({ onClose }: OpsPanelProps) { + const [sessions, setSessions] = useState([]); + const [statusBySession, setStatusBySession] = useState>({}); + const [activeSessionId, setActiveSessionId] = useState(null); + const [hosts, setHosts] = useState([]); + const [hostsLoading, setHostsLoading] = useState(true); + const [configPath, setConfigPath] = useState("~/.ssh/config"); + const [quickTarget, setQuickTarget] = useState(""); + const [connecting, setConnecting] = useState(false); + const [loadError, setLoadError] = useState(null); + + const refreshHosts = () => { + if (!isElectronRuntime()) { + setLoadError("Ops panel is available in the desktop app."); + setHostsLoading(false); + return; + } + const listHosts = window.__IPOLLOWORK_ELECTRON__?.ssh?.listHosts; + if (!listHosts) { + setLoadError("SSH bridge is unavailable."); + setHostsLoading(false); + return; + } + setHostsLoading(true); + void listHosts() + .then(({ hosts: hostList, configPath: config }) => { + setHosts(hostList ?? []); + setConfigPath(config ?? "~/.ssh/config"); + setLoadError(null); + }) + .catch((error) => { + setLoadError(error instanceof Error ? error.message : "Could not read SSH config."); + }) + .finally(() => setHostsLoading(false)); + }; + + useEffect(() => { + refreshHosts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addSession = (target: string) => { + const clean = normalizeSshTarget(target); + if (!clean) return; + const id = `ops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + setSessions((prev) => [ + ...prev, + { id, label: clean, target: clean, terminalId: null, exited: false }, + ]); + setActiveSessionId(id); + setQuickTarget(""); + }; + + const connectQuick = () => { + const target = quickTarget.trim(); + if (!target || connecting) return; + setConnecting(true); + // Defer so the terminal pane mounts before the SSH connection starts. + window.setTimeout(() => { + addSession(target); + setConnecting(false); + }, 0); + }; + + const closeSession = (sessionId: string) => { + setSessions((prev) => prev.filter((session) => session.id !== sessionId)); + setStatusBySession((prev) => { + const next = { ...prev }; + delete next[sessionId]; + return next; + }); + setActiveSessionId((current) => { + if (current !== sessionId) return current; + const remaining = sessions.filter((session) => session.id !== sessionId); + return remaining.length ? remaining[remaining.length - 1].id : null; + }); + }; + + const handleStatus = (sessionId: string, status: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: status })); + }; + + const handleExit = (sessionId: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: "Disconnected" })); + }; + + const activeSession = sessions.find((session) => session.id === activeSessionId) ?? null; + + return ( +
+
+
+ + 运维面板 + SSH +
+
+ + {onClose ? ( + + ) : null} +
+
+ +
+ + +
+
+ {sessions.length === 0 ? ( + 没有活动的 SSH 会话 + ) : ( + sessions.map((session) => ( +
+ + +
+ )) + )} +
+ +
+ {activeSession ? ( + <> +
+ +
+ {activeSession ? ( + setActiveSessionId(activeSession.id)} + /> + ) : ( +
+

+ 在左侧选择一台主机,或输入 user@host 开始 SSH 会话。 +

+
+ )} +
+
+
+
+ ); +} diff --git a/apps/app/src/react-app/domains/session/ops/ops-utils.ts b/apps/app/src/react-app/domains/session/ops/ops-utils.ts new file mode 100644 index 000000000..8163d1ee4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-utils.ts @@ -0,0 +1,21 @@ +/** + * Pure helpers for the SSH ops panel. + * + * Framework-free so they can be unit-tested without React/DOM. + */ + +/** Normalize a raw SSH connect target: strip a leading `ssh`/`-t` and whitespace. */ +export function normalizeSshTarget(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return ""; + let cleaned = trimmed; + const prefix = /^ssh\s+/; + if (prefix.test(cleaned)) { + cleaned = cleaned.replace(prefix, ""); + } + const flag = /^-t\s+/; + if (flag.test(cleaned)) { + cleaned = cleaned.replace(flag, ""); + } + return cleaned.trim(); +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts new file mode 100644 index 000000000..a096e44e2 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts @@ -0,0 +1,170 @@ +// Standard 5-field cron matcher + next-run calculator. Kept dependency-free +// so both the renderer (live preview) and the Electron scheduler can rely on +// the same field semantics. Mirrors the model used by opencode-scheduler and +// opencode-tasks: +// +// ┌───────────── minute (0-59) +// │ ┌───────────── hour (0-23) +// │ │ ┌───────────── day of month (1-31) +// │ │ │ ┌───────────── month (1-12) +// │ │ │ │ ┌───────────── day of week (0-6, Sunday=0; 7 = Sunday) +// │ │ │ │ │ +// * * * * * + +const MAX_SCAN_MINUTES = 366 * 24 * 60 * 4; // 4 years covers the leap-year cycle + +export const CRON_FIELDS = ["minute", "hour", "day", "month", "weekday"] as const; +export type CronFieldName = (typeof CRON_FIELDS)[number]; + +export type CronField = { + min: number; + max: number; + values: Set; +}; + +type CronParse = { + fields: Record; + raw: Record; +}; + +function parseFieldPart(part: string, min: number, max: number, allowSeven: boolean): Set | null { + const values = new Set(); + const add = (n: number) => { + if (n < min || n > max) return false; + if (!allowSeven && n === 7) return false; + values.add(n); + return true; + }; + + for (const raw of part.split(",")) { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (!/^(?:\*|\d+)(?:-\d+)?(?:\/\d+)?$/.test(trimmed)) return null; + + if (trimmed === "*") { + for (let i = min; i <= max; i++) values.add(i); + continue; + } + + const stepMatch = /^(\*|\d+-\d+|\d+)\/(\d+)$/.exec(trimmed); + if (stepMatch) { + const base = stepMatch[1]; + const step = Number.parseInt(stepMatch[2], 10); + if (!Number.isInteger(step) || step <= 0) return null; + + let rangeStart = min; + let rangeEnd = max; + if (base !== "*") { + if (base.includes("-")) { + const [a, b] = base.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b)) return null; + rangeStart = a; + rangeEnd = b; + } else { + const single = Number.parseInt(base, 10); + if (!Number.isInteger(single)) return null; + rangeStart = single; + rangeEnd = max; + } + } + for (let i = rangeStart; i <= rangeEnd; i += step) { + if (!add(i)) return null; + } + continue; + } + + if (trimmed.includes("-")) { + const [a, b] = trimmed.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b) || a > b) return null; + for (let i = a; i <= b; i++) { + if (!add(i)) return null; + } + continue; + } + + const single = Number.parseInt(trimmed, 10); + if (!Number.isInteger(single)) return null; + if (!add(single)) return null; + } + + return values; +} + +function buildField(name: CronFieldName, raw: string): CronField | null { + const min = name === "month" || name === "day" ? 1 : 0; + const max = name === "month" ? 12 : name === "day" ? 31 : name === "weekday" ? 7 : name === "hour" ? 23 : 59; + const allowSeven = name === "weekday"; + const values = parseFieldPart(raw, min, max, allowSeven); + if (!values || values.size === 0) return null; + return { min, max, values }; +} + +export function parseCron(expression: string): CronParse | null { + const trimmed = expression.trim(); + const parts = trimmed.split(/\s+/); + if (parts.length !== 5) return null; + + const minute = buildField("minute", parts[0]); + const hour = buildField("hour", parts[1]); + const day = buildField("day", parts[2]); + const month = buildField("month", parts[3]); + const weekday = buildField("weekday", parts[4]); + if (!minute || !hour || !day || !month || !weekday) return null; + + return { + fields: { minute, hour, day, month, weekday }, + raw: { minute: parts[0], hour: parts[1], day: parts[2], month: parts[3], weekday: parts[4] }, + }; +} + +function normalizeWeekday(weekday: number): number { + return weekday === 7 ? 0 : weekday; +} + +function weekdayMatches(field: CronField, date: Date): boolean { + const dow = date.getDay(); + if (field.values.has(normalizeWeekday(dow))) return true; + return field.values.has(7) && dow === 0; +} + +function matchesParsed(parsed: CronParse, date: Date): boolean { + const { fields, raw } = parsed; + + const minute = fields.minute.values.has(date.getMinutes()); + const hour = fields.hour.values.has(date.getHours()); + const month = fields.month.values.has(date.getMonth() + 1); + const day = fields.day.values.has(date.getDate()); + const dow = weekdayMatches(fields.weekday, date); + + const dayRestricted = !raw.day.includes("*"); + const dowRestricted = !raw.weekday.includes("*"); + + const dayMatch = dayRestricted && dowRestricted ? day || dow : day && dow; + return minute && hour && month && dayMatch; +} + +export function cronMatches(expression: string, date: Date): boolean { + const parsed = parseCron(expression); + if (!parsed) return false; + return matchesParsed(parsed, date); +} + +export function isValidCron(expression: string): boolean { + return parseCron(expression) !== null; +} + +/** Next run strictly after `after` (inclusive minute boundary handled by caller). */ +export function nextRunAfter(expression: string, after: Date): Date | null { + const parsed = parseCron(expression); + if (!parsed) return null; + + const cursor = new Date(after.getTime()); + cursor.setSeconds(0, 0); + cursor.setMinutes(cursor.getMinutes() + 1); + + for (let i = 0; i < MAX_SCAN_MINUTES; i++) { + if (matchesParsed(parsed, cursor)) return new Date(cursor.getTime()); + cursor.setMinutes(cursor.getMinutes() + 1); + } + return null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts new file mode 100644 index 000000000..2b90e84d6 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts @@ -0,0 +1,63 @@ +export type ScheduledTaskTemplate = { + id: string; + title: string; + description: string; + cron: string; + prompt: string; +}; + +export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ + { + id: "daily-standup", + title: "每日站会摘要", + description: "每个工作日汇总昨日进展、今日计划与风险项。", + cron: "0 9 * * 1-5", + prompt: + "基于当前工作区最近的会话与提交记录,生成一份每日站会摘要:昨日完成的关键进展、今日计划、以及需要关注的风险或阻塞项。用简洁的要点列表呈现。", + }, + { + id: "weekly-report", + title: "每周工作周报", + description: "每周一生成结构化周报。", + cron: "0 8 * * 1", + prompt: + "汇总过去 7 天的工作内容,生成一份结构化周报,包含:本周完成事项、关键数据或产出、下周计划、需要协作或支持的事项。", + }, + { + id: "daily-news", + title: "每日新闻/行业简报", + description: "每天抓取并整理当日资讯简报。", + cron: "0 7 * * *", + prompt: + "收集并整理今日与所在行业相关的新闻与动态,生成一份精炼的每日简报:重要新闻标题与一句话摘要、对业务的潜在影响、值得关注的机会或风险。", + }, + { + id: "data-report", + title: "数据日报", + description: "定时汇总关键数据指标。", + cron: "0 9 * * *", + prompt: + "汇总当前工作区可获取的关键数据指标,生成一份数据日报:核心指标数值、环比变化、异常波动说明、以及简要结论。", + }, + { + id: "meeting-followup", + title: "会议纪要跟进", + description: "定时提醒并跟进会议待办事项。", + cron: "0 10 * * 1-5", + prompt: + "回顾最近的会议纪要,提取其中尚未完成的待办事项与责任人,生成一份跟进清单:待办事项、责任人、截止时间、当前状态与下一步建议。", + }, + { + id: "todo-scan", + title: "提醒/待办巡检", + description: "定时检查待办与到期事项并提醒。", + cron: "0 */6 * * *", + prompt: + "巡检当前工作区内的待办事项与到期日程,找出即将到期或已逾期的事项,生成提醒清单并给出优先级建议。", + }, +]; + +export function templateById(id: string | null | undefined): ScheduledTaskTemplate | null { + if (!id) return null; + return SCHEDULED_TASK_TEMPLATES.find((t) => t.id === id) ?? null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts new file mode 100644 index 000000000..3ada675f4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts @@ -0,0 +1,36 @@ +export type ScheduledTaskStatus = "ok" | "error" | "skipped"; + +export type ScheduledTask = { + id: string; + name: string; + description: string; + cron: string; + workspaceId: string; + prompt: string; + enabled: boolean; + templateId: string | null; + createdAt: number; + lastRunAt: number | null; + lastRunStatus: ScheduledTaskStatus | null; + nextRunAt?: number | null; +}; + +export type ScheduledTaskLogEntry = { + at: number; + status: ScheduledTaskStatus; + message: string; +}; + +export type ScheduledTaskCreateInput = { + name?: string; + description?: string; + cron?: string; + workspaceId?: string; + prompt?: string; + enabled?: boolean; + templateId?: string | null; +}; + +export type ScheduledTaskUpdatePatch = Partial< + Pick +>; diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx new file mode 100644 index 000000000..50ccb41c9 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx @@ -0,0 +1,368 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { CalendarClock, Clock, Loader2, Play, Plus, RefreshCw, Trash2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { isValidCron, nextRunAfter } from "./cron"; +import { SCHEDULED_TASK_TEMPLATES, type ScheduledTaskTemplate } from "./scheduled-task-templates"; +import type { ScheduledTask, ScheduledTaskLogEntry } from "./scheduled-task"; + +type ScheduledTasksPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +type Draft = { + id: string | null; + name: string; + description: string; + cron: string; + prompt: string; + templateId: string | null; +}; + +function emptyDraft(): Draft { + return { id: null, name: "", description: "", cron: "", prompt: "", templateId: null }; +} + +function draftFromTemplate(template: ScheduledTaskTemplate): Draft { + return { + id: null, + name: template.title, + description: template.description, + cron: template.cron, + prompt: template.prompt, + templateId: template.id, + }; +} + +function draftFromTask(task: ScheduledTask): Draft { + return { + id: task.id, + name: task.name, + description: task.description, + cron: task.cron, + prompt: task.prompt, + templateId: task.templateId, + }; +} + +function formatTime(value: number | null | undefined): string { + if (!value) return "—"; + return new Date(value).toLocaleString(); +} + +function cronHint(expression: string): string { + if (!expression.trim()) return "请输入 5 段 cron 表达式"; + if (!isValidCron(expression)) return "无效的 cron 表达式"; + const next = nextRunAfter(expression, new Date()); + return next ? `下次运行:${new Date(next).toLocaleString()}` : "无法计算下次运行时间"; +} + +export function ScheduledTasksPanel({ workspaceRoot, onClose }: ScheduledTasksPanelProps) { + const bridge = typeof window !== "undefined" ? window.__IPOLLOWORK_ELECTRON__?.scheduledTasks : undefined; + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!bridge?.list) { + setTasks([]); + setLoading(false); + return; + } + try { + const result = await bridge.list(); + setTasks(result ?? []); + } finally { + setLoading(false); + } + }, [bridge]); + + useEffect(() => { + void refresh(); + const off = bridge?.onChanged?.(() => { + void refresh(); + }); + return () => off?.(); + }, [bridge, refresh]); + + const selectedTask = useMemo( + () => tasks.find((task) => task.id === selectedId) ?? null, + [tasks, selectedId], + ); + + useEffect(() => { + if (!selectedTask || !bridge?.logs) { + setLogs([]); + return; + } + void bridge.logs(selectedTask.id).then((result) => setLogs(result ?? [])); + }, [selectedTask, bridge]); + + const openNew = () => { + setSelectedId(null); + setDraft(null); + setLogs([]); + }; + + const openEdit = (task: ScheduledTask) => { + setSelectedId(task.id); + setDraft(draftFromTask(task)); + }; + + const pickTemplate = (template: ScheduledTaskTemplate) => { + setSelectedId(null); + setDraft(draftFromTemplate(template)); + }; + + const save = async () => { + if (!draft || !bridge?.create || !bridge?.update) return; + if (!draft.name.trim() || !isValidCron(draft.cron) || !draft.prompt.trim()) return; + setBusy(true); + try { + const input = { + name: draft.name, + description: draft.description, + cron: draft.cron, + prompt: draft.prompt, + workspaceId: workspaceRoot, + templateId: draft.templateId, + }; + const saved = draft.id + ? await bridge.update(draft.id, input) + : await bridge.create(input); + await refresh(); + if (saved) { + setSelectedId(saved.id); + setDraft(draftFromTask(saved)); + } + } finally { + setBusy(false); + } + }; + + const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => { + if (!bridge?.setEnabled) return; + await bridge.setEnabled(task.id, enabled); + void refresh(); + }; + + const runNow = async (task: ScheduledTask) => { + if (!bridge?.runNow) return; + setBusy(true); + try { + await bridge.runNow(task.id); + await refresh(); + } finally { + setBusy(false); + } + }; + + const remove = async (task: ScheduledTask) => { + if (!bridge?.remove) return; + await bridge.remove(task.id); + setSelectedId(null); + setDraft(null); + void refresh(); + }; + + const available = Boolean(bridge); + const cronValue = draft?.cron ?? ""; + + return ( +
+
+
+ + 定时任务 + {tasks.length} 个 +
+
+ + {onClose ? ( + + ) : null} +
+
+ + {!available ? ( +
+ 定时任务面板仅在桌面应用可用。 +
+ ) : ( +
+ + +
+ {!draft ? ( +
+

选择模板

+
+ {SCHEDULED_TASK_TEMPLATES.map((template) => ( + + ))} +
+
+ ) : ( +
+
+
+ + setDraft({ ...draft, name: event.target.value })} + placeholder="例如:每日站会摘要" + /> +
+
+ + setDraft({ ...draft, cron: event.target.value })} + placeholder="0 9 * * 1-5" + className="font-mono" + /> +

+ {cronHint(cronValue)} +

+
+
+ +