feat(console): automation panel edit and richer list

- AutomationPanel rows now show title, prompt content (160-char preview with expand/collapse), repo_url, last/next run, run_once badge and cancelled time.

- Active automations get an inline edit form (name/prompt/interval/repo) that PATCHes only changed fields via the existing automation_update endpoint; backend semantics unchanged (no last_run_at reset, cancelled stays terminal).

- Rebuilt cloud/console.html with tsc + vite (build green); automation python tests 18/18 OK.
This commit is contained in:
host committed 2026-10-30 15:31:21 +08:00
1 parent e2ce04ce4f
commit 2d644f901b
7 files changed
+117 -35

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ export const paths = {
projectMcpReload: (pid: string) => "/projects/" + enc(pid) + "/mcp-servers/reload",
projectMcpOauth: (pid: string, name: string) => "/projects/" + enc(pid) + "/mcp-servers/" + enc(name) + "/oauth-login",
projectAutomations: (pid: string) => "/projects/" + enc(pid) + "/automations",
projectAutomation: (pid: string, id: string) => "/projects/" + enc(pid) + "/automations/" + enc(id),
projectAutomationCancel: (pid: string, id: string) => "/projects/" + enc(pid) + "/automations/" + enc(id) + "/cancel",
projectTriage: (pid: string) => "/projects/" + enc(pid) + "/triage",
projectTodos: (pid: string) => "/projects/" + enc(pid) + "/todos",
+1 -1
View File
@@ -36,7 +36,7 @@ export interface Followup { mode?: string; status?: string; sender_task_id?: str
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null }
export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string }
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
export interface Automation { id: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; created_at?: number; cancelled_at?: number; run_once?: boolean }
export interface Member { user_id: string; username?: string; role?: string }
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
+1 -1
View File
@@ -35,7 +35,7 @@ export interface ConsoleCtx {
followups: Followup[];
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; cancelAutomation: (id: string) => void;
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void;
triage: Triage | null; triageError: string; refreshTriage: () => void;
threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void;
dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string;
@@ -144,7 +144,7 @@ export function useConsoleStore(): ConsoleCtx {
followups: detail.followups,
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, cancelAutomation: tools.cancelAutomation,
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation,
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
dispatch: async (body, atts) => {
@@ -35,6 +35,33 @@ export function useProjectTools(getProjectId: () => string) {
} catch (e: any) { setAutoMsg("创建失败:" + e.message); }
}, [getProjectId, refreshAutomations]);
const updateAutomation = useCallback(async (a: Automation, name: string, prompt: string, interval: string, repo: string) => {
const pid = getProjectId();
if (!pid) { setAutoMsg("先选项目"); return false; }
const n = name.trim();
const p = prompt.trim();
if (!n) { setAutoMsg("自动化名称必填"); return false; }
if (!p) { setAutoMsg("定时任务内容必填"); return false; }
const body: { name?: string; prompt?: string; interval_seconds?: number; repo_url?: string | null } = {};
if (n !== (a.name || "")) body.name = n;
if (p !== (a.prompt || "")) body.prompt = p;
if (interval.trim()) {
const v = parseInt(interval.trim(), 10);
if (Number.isNaN(v)) { setAutoMsg("间隔秒数必须是数字"); return false; }
if (v < 60) { setAutoMsg("间隔秒数最小 60"); return false; }
if (v !== a.interval_seconds) body.interval_seconds = v;
}
const r = repo.trim();
if (r !== (a.repo_url || "")) body.repo_url = r === "" ? null : r;
if (Object.keys(body).length === 0) { setAutoMsg("没有改动"); return false; }
try {
await api(paths.projectAutomation(pid, a.id), "PATCH", body);
setAutoMsg("自动化已更新");
await refreshAutomations(pid);
return true;
} catch (e: any) { setAutoMsg("更新失败:" + e.message); return false; }
}, [getProjectId, refreshAutomations]);
const cancelAutomation = useCallback((id: string) => {
const pid = getProjectId();
if (!pid) return;
@@ -53,5 +80,5 @@ export function useProjectTools(getProjectId: () => string) {
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
}, [getProjectId]);
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, cancelAutomation, refreshTriage };
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, refreshTriage };
}
@@ -2,6 +2,17 @@ import { useState } from "react";
import { useConsole } from "../../context";
import { Bar, DataList } from "../../components/ui";
import { fmt, shortId } from "../../api/client";
import type { Automation } from "../../api/types";
const PROMPT_PREVIEW = 160;
function humanInterval(s?: number): string {
if (!s) return "";
if (s % 86400 === 0) return "(每天)";
if (s % 3600 === 0) return "(每小时)";
if (s % 60 === 0) return "(每 " + s / 60 + " 分钟)";
return "";
}
export function AutomationPanel() {
const ctx = useConsole();
@@ -9,6 +20,19 @@ export function AutomationPanel() {
const [prompt, setPrompt] = useState("");
const [interval, setInterval] = useState("");
const [repo, setRepo] = useState("");
const [editingId, setEditingId] = useState("");
const [editName, setEditName] = useState("");
const [editPrompt, setEditPrompt] = useState("");
const [editInterval, setEditInterval] = useState("");
const [editRepo, setEditRepo] = useState("");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const startEdit = (a: Automation) => {
setEditingId(a.id);
setEditName(a.name || "");
setEditPrompt(a.prompt || "");
setEditInterval(a.interval_seconds ? String(a.interval_seconds) : "");
setEditRepo(a.repo_url || "");
};
return (
<>
<input placeholder="自动化名称(同项目内启用中的名称唯一)" style={{ width: "100%" }} value={name} onChange={(e) => setName(e.target.value)} />
@@ -20,12 +44,42 @@ export function AutomationPanel() {
<button onClick={ctx.refreshAutomations}>刷新自动化</button>
<span className="muted">{ctx.autoMsg}</span>
</Bar>
<DataList empty="(暂无自动化)" items={ctx.automations.map((a) => (
<span key={a.id} className="row-main">
[{a.status}{a.due ? "|到期" : ""}] {a.name}|每 {a.interval_seconds} 秒|下次 {fmt(a.next_run_at)}
{a.status === "active" ? <> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
</span>
))} />
<DataList empty="(暂无自动化)" items={ctx.automations.map((a) => {
const full = a.prompt || "";
const showFull = !!expanded[a.id];
const tooLong = full.length > PROMPT_PREVIEW;
const shown = !full ? "(无内容)" : (showFull || !tooLong ? full : full.slice(0, PROMPT_PREVIEW) + "…");
return (
<div key={a.id}>
<div className="row-main">
[{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)}
{a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
</div>
{a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null}
{a.status === "cancelled" && a.cancelled_at ? <div className="muted">已取消于 {fmt(a.cancelled_at)}</div> : null}
<div className="muted" style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{shown}
{tooLong ? <button onClick={() => setExpanded((prev) => ({ ...prev, [a.id]: !prev[a.id] }))}>{showFull ? "收起" : "展开"}</button> : null}
</div>
{editingId === a.id ? (
<div>
<input placeholder="自动化名称" style={{ width: "100%" }} value={editName} onChange={(e) => setEditName(e.target.value)} />
<textarea placeholder="定时任务内容" value={editPrompt} onChange={(e) => setEditPrompt(e.target.value)} />
<Bar>
<input placeholder="间隔秒数(最小 60,留空保持原值)" style={{ width: "40%" }} value={editInterval} onChange={(e) => setEditInterval(e.target.value)} />
<input placeholder="仓库(可选,清空则清除)" style={{ width: "40%" }} value={editRepo} onChange={(e) => setEditRepo(e.target.value)} />
<button className="primary" onClick={async () => {
const cur = ctx.automations.find((x) => x.id === editingId) || a;
const ok = await ctx.updateAutomation(cur, editName, editPrompt, editInterval, editRepo);
if (ok) setEditingId("");
}}>保存</button>
<button onClick={() => setEditingId("")}>取消</button>
</Bar>
</div>
) : null}
</div>
);
})} />
</>
);
}