From ccf2ed4703291146698ba18dd309a79e972c93bd Mon Sep 17 00:00:00 2001 From: lpf Date: Wed, 4 Mar 2026 14:39:46 +0800 Subject: [PATCH] feat(webui): add safety confirms and local-time rendering; support install.sh -ui --- install.sh | 96 +++++++++++++++++++++++++---------- webui/src/i18n/index.ts | 36 +++++++++++++ webui/src/pages/Config.tsx | 11 +++- webui/src/pages/Cron.tsx | 22 +++++++- webui/src/pages/Logs.tsx | 29 +++++------ webui/src/pages/Memory.tsx | 9 ++++ webui/src/pages/TaskAudit.tsx | 36 +++++++++++-- webui/src/pages/Tasks.tsx | 13 +++++ webui/src/utils/time.ts | 49 ++++++++++++++++++ 9 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 webui/src/utils/time.ts diff --git a/install.sh b/install.sh index 01c761c..a2654ef 100644 --- a/install.sh +++ b/install.sh @@ -9,6 +9,35 @@ REPO="clawgo" BIN="clawgo" INSTALL_DIR="/usr/local/bin" WEBUI_DIR="$HOME/.clawgo/workspace/webui" +UI_ONLY=0 + +usage() { + cat < /dev/null; then - echo "$BIN is already installed. Removing existing version..." - sudo rm -f "$INSTALL_DIR/$BIN" +if [[ "$UI_ONLY" -eq 0 ]]; then + if command -v "$BIN" &> /dev/null; then + echo "$BIN is already installed. Removing existing version..." + sudo rm -f "$INSTALL_DIR/$BIN" + fi +else + echo "UI-only mode enabled: skip binary uninstall/install." fi # ==================== @@ -57,36 +90,36 @@ WEBUI_FILE="webui.tar.gz" URL="https://github.com/$OWNER/$REPO/releases/download/$TAG/$FILE" WEBUI_URL="https://github.com/$OWNER/$REPO/releases/download/$TAG/$WEBUI_FILE" -echo "Trying to download: $URL" - -# Try to download binary release TMPDIR="$(mktemp -d)" -OUT="$TMPDIR/$FILE" +if [[ "$UI_ONLY" -eq 0 ]]; then + echo "Trying to download: $URL" + OUT="$TMPDIR/$FILE" -# Now try downloading the file -if curl -fSL "$URL" -o "$OUT"; then - echo "Downloaded $FILE" - tar -xzf "$OUT" -C "$TMPDIR" + # Now try downloading the file + if curl -fSL "$URL" -o "$OUT"; then + echo "Downloaded $FILE" + tar -xzf "$OUT" -C "$TMPDIR" - EXTRACTED_BIN="" - if [[ -f "$TMPDIR/$BIN" ]]; then - EXTRACTED_BIN="$TMPDIR/$BIN" + EXTRACTED_BIN="" + if [[ -f "$TMPDIR/$BIN" ]]; then + EXTRACTED_BIN="$TMPDIR/$BIN" + else + EXTRACTED_BIN="$(find "$TMPDIR" -maxdepth 2 -type f -name "${BIN}*" ! -name "*.tar.gz" ! -name "*.zip" | head -n1)" + fi + + if [[ -z "$EXTRACTED_BIN" || ! -f "$EXTRACTED_BIN" ]]; then + echo "Failed to locate extracted binary from $FILE" + exit 1 + fi + + chmod +x "$EXTRACTED_BIN" + echo "Installing $BIN to $INSTALL_DIR (may require sudo)..." + sudo mv "$EXTRACTED_BIN" "$INSTALL_DIR/$BIN" + echo "Installed $BIN to $INSTALL_DIR/clawgo" else - EXTRACTED_BIN="$(find "$TMPDIR" -maxdepth 2 -type f -name "${BIN}*" ! -name "*.tar.gz" ! -name "*.zip" | head -n1)" - fi - - if [[ -z "$EXTRACTED_BIN" || ! -f "$EXTRACTED_BIN" ]]; then - echo "Failed to locate extracted binary from $FILE" + echo "No prebuilt binary found, exiting..." exit 1 fi - - chmod +x "$EXTRACTED_BIN" - echo "Installing $BIN to $INSTALL_DIR (may require sudo)..." - sudo mv "$EXTRACTED_BIN" "$INSTALL_DIR/$BIN" - echo "Installed $BIN to $INSTALL_DIR/clawgo" -else - echo "No prebuilt binary found, exiting..." - exit 1 fi # ==================== @@ -123,6 +156,7 @@ fi # ==================== # Migrate (Embedded openclaw2clawgo Script) # ==================== +if [[ "$UI_ONLY" -eq 0 ]]; then read -p "Do you want to migrate your OpenClaw workspace to ClawGo? (y/n): " MIGRATE if [[ "$MIGRATE" == "y" || "$MIGRATE" == "Y" ]]; then echo "Choose migration type: " @@ -239,6 +273,7 @@ done if [[ -d "$DST/memory" ]]; then cp -a "$DST/memory" "$BACKUP_DIR/memory" || true fi +fi # Migrate core persona/context files for f in AGENTS.md SOUL.md USER.md IDENTITY.md TOOLS.md MEMORY.md HEARTBEAT.md; do @@ -268,9 +303,14 @@ EOF ;; esac fi +fi echo "Cleaning up..." rm -rf "$TMPDIR" echo "Done 🎉" -echo "Run 'clawgo --help' to verify" +if [[ "$UI_ONLY" -eq 0 ]]; then + echo "Run 'clawgo --help' to verify" +else + echo "WebUI update finished." +fi diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index cdc59d3..e5d85d7 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -221,6 +221,24 @@ const resources = { inputPreview: 'Input Preview', blockReason: 'Block Reason', actionFailed: 'Action failed', + cronDeleteConfirmTitle: 'Delete Cron Job', + cronDeleteConfirmMessage: 'This will permanently delete the cron job. Continue?', + cronDisableConfirmTitle: 'Pause Cron Job', + cronDisableConfirmMessage: 'Pause this cron job now?', + memoryDeleteConfirmTitle: 'Delete Memory File', + memoryDeleteConfirmMessage: 'Delete memory file "{{path}}" permanently?', + taskDeleteConfirmTitle: 'Delete Task', + taskDeleteConfirmMessage: 'Delete task "{{id}}" permanently?', + logsClearConfirmTitle: 'Clear Logs', + logsClearConfirmMessage: 'Clear current log list from this page?', + configDeleteProviderConfirmTitle: 'Delete Provider', + configDeleteProviderConfirmMessage: 'Delete provider "{{name}}" from current config?', + taskPauseConfirmTitle: 'Pause Task', + taskPauseConfirmMessage: 'Pause task "{{id}}" now?', + taskCompleteConfirmTitle: 'Complete Task', + taskCompleteConfirmMessage: 'Mark task "{{id}}" as completed?', + taskIgnoreConfirmTitle: 'Ignore Task', + taskIgnoreConfirmMessage: 'Ignore task "{{id}}"? This may hide follow-up processing.', cronExpressionPlaceholder: '*/5 * * * *', recipientId: 'recipient id', languageZh: '中文', @@ -643,6 +661,24 @@ const resources = { inputPreview: '输入预览', blockReason: '阻断原因', actionFailed: '操作失败', + cronDeleteConfirmTitle: '删除定时任务', + cronDeleteConfirmMessage: '此操作会永久删除该定时任务,是否继续?', + cronDisableConfirmTitle: '暂停定时任务', + cronDisableConfirmMessage: '确认暂停该定时任务吗?', + memoryDeleteConfirmTitle: '删除记忆文件', + memoryDeleteConfirmMessage: '确认永久删除记忆文件“{{path}}”吗?', + taskDeleteConfirmTitle: '删除任务', + taskDeleteConfirmMessage: '确认永久删除任务“{{id}}”吗?', + logsClearConfirmTitle: '清空日志', + logsClearConfirmMessage: '确认清空当前页面中的日志列表吗?', + configDeleteProviderConfirmTitle: '删除 Provider', + configDeleteProviderConfirmMessage: '确认从当前配置中删除 provider “{{name}}”吗?', + taskPauseConfirmTitle: '暂停任务', + taskPauseConfirmMessage: '确认暂停任务“{{id}}”吗?', + taskCompleteConfirmTitle: '完成任务', + taskCompleteConfirmMessage: '确认将任务“{{id}}”标记为完成吗?', + taskIgnoreConfirmTitle: '忽略任务', + taskIgnoreConfirmMessage: '确认忽略任务“{{id}}”吗?这可能会跳过后续处理。', cronExpressionPlaceholder: '*/5 * * * *', recipientId: '接收者 ID', languageZh: '中文', diff --git a/webui/src/pages/Config.tsx b/webui/src/pages/Config.tsx index 5809edd..ab29374 100644 --- a/webui/src/pages/Config.tsx +++ b/webui/src/pages/Config.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { RefreshCw, Save } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; import RecursiveConfig from '../components/RecursiveConfig'; function setPath(obj: any, path: string, value: any) { @@ -19,6 +20,7 @@ function setPath(obj: any, path: string, value: any) { const Config: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { cfg, setCfg, cfgRaw, setCfgRaw, loadConfig, hotReloadFieldDetails, q } = useAppContext(); const [showRaw, setShowRaw] = useState(false); const [basicMode, setBasicMode] = useState(true); @@ -94,7 +96,14 @@ const Config: React.FC = () => { setCfg((v) => setPath(v, `providers.proxies.${name}.${field}`, value)); } - function removeProxy(name: string) { + async function removeProxy(name: string) { + const ok = await ui.confirmDialog({ + title: t('configDeleteProviderConfirmTitle'), + message: t('configDeleteProviderConfirmMessage', { name }), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; setCfg((v) => { const next = JSON.parse(JSON.stringify(v || {})); if (next?.providers?.proxies && typeof next.providers.proxies === 'object') { diff --git a/webui/src/pages/Cron.tsx b/webui/src/pages/Cron.tsx index b187f23..8c3dfd2 100644 --- a/webui/src/pages/Cron.tsx +++ b/webui/src/pages/Cron.tsx @@ -3,7 +3,9 @@ import { Plus, RefreshCw, CheckCircle2, Pause, Edit2, Trash2, X, Play, Clock } f import { motion, AnimatePresence } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; import { CronJob } from '../types'; +import { formatLocalDateTime } from '../utils/time'; const initialCronForm = { name: '', @@ -37,7 +39,7 @@ const formatSchedule = (job: CronJob, t: (key: string) => string) => { if (kind === 'at' && job.schedule?.atMs) { return { label: t('runAt'), - value: new Date(job.schedule.atMs).toLocaleString(), + value: formatLocalDateTime(job.schedule.atMs), }; } return { @@ -48,6 +50,7 @@ const formatSchedule = (job: CronJob, t: (key: string) => string) => { const Cron: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { cron, refreshCron, q, cfg } = useAppContext(); const [isCronModalOpen, setIsCronModalOpen] = useState(false); const [editingCron, setEditingCron] = useState(null); @@ -72,6 +75,23 @@ const Cron: React.FC = () => { }, [cfg, enabledChannels]); async function cronAction(action: 'delete' | 'enable' | 'disable', id: string) { + if (action === 'delete') { + const ok = await ui.confirmDialog({ + title: t('cronDeleteConfirmTitle'), + message: t('cronDeleteConfirmMessage'), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; + } + if (action === 'disable') { + const ok = await ui.confirmDialog({ + title: t('cronDisableConfirmTitle'), + message: t('cronDisableConfirmMessage'), + confirmText: t('pause'), + }); + if (!ok) return; + } try { await fetch(`/webui/api/cron${q}`, { method: 'POST', diff --git a/webui/src/pages/Logs.tsx b/webui/src/pages/Logs.tsx index 7ea23ce..e42b880 100644 --- a/webui/src/pages/Logs.tsx +++ b/webui/src/pages/Logs.tsx @@ -2,10 +2,13 @@ import React, { useEffect, useState, useRef } from 'react'; import { Terminal, Trash2, Play, Square } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; import { LogEntry } from '../types'; +import { formatLocalTime } from '../utils/time'; const Logs: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { q } = useAppContext(); const [logs, setLogs] = useState([]); const [codeMap, setCodeMap] = useState>({}); @@ -109,7 +112,16 @@ const Logs: React.FC = () => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [logs]); - const clearLogs = () => setLogs([]); + const clearLogs = async () => { + const ok = await ui.confirmDialog({ + title: t('logsClearConfirmTitle'), + message: t('logsClearConfirmMessage'), + danger: true, + confirmText: t('clear'), + }); + if (!ok) return; + setLogs([]); + }; const normalizeLog = (v: any): LogEntry => ({ time: typeof v?.time === 'string' && v.time ? v.time : (typeof v?.timestamp === 'string' && v.timestamp ? v.timestamp : new Date().toISOString()), @@ -135,19 +147,6 @@ const Logs: React.FC = () => { return codeMap[c] || v; }; - const formatTime = (raw: string) => { - try { - if (!raw) return '--:--:--'; - if (raw.includes('T')) { - const right = raw.split('T')[1] || ''; - return (right.split('.')[0] || right).trim() || '--:--:--'; - } - return raw; - } catch { - return '--:--:--'; - } - }; - const renderReadable = (log: LogEntry) => { const keys = Object.keys(log).filter(k => !['time', 'level', 'msg', '__raw'].includes(k)); const core = `${log.msg}`; @@ -241,7 +240,7 @@ const Logs: React.FC = () => { const code = toCode(rawCode); return ( - {formatTime(log.time)} + {formatLocalTime(log.time)} {lvl} {message} {errText} diff --git a/webui/src/pages/Memory.tsx b/webui/src/pages/Memory.tsx index b52c154..61d4002 100644 --- a/webui/src/pages/Memory.tsx +++ b/webui/src/pages/Memory.tsx @@ -1,9 +1,11 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; const Memory: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { q } = useAppContext(); const [files, setFiles] = useState([]); const [active, setActive] = useState(''); @@ -35,6 +37,13 @@ const Memory: React.FC = () => { } async function removeFile(path: string) { + const ok = await ui.confirmDialog({ + title: t('memoryDeleteConfirmTitle'), + message: t('memoryDeleteConfirmMessage', { path }), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; await fetch(`/webui/api/memory${qp('path', path)}`, { method: 'DELETE' }); if (active === path) { setActive(''); diff --git a/webui/src/pages/TaskAudit.tsx b/webui/src/pages/TaskAudit.tsx index 43a079a..dcb248c 100644 --- a/webui/src/pages/TaskAudit.tsx +++ b/webui/src/pages/TaskAudit.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; +import { formatLocalDateTime, localDateInputValue } from '../utils/time'; type TaskAuditItem = { task_id?: string; @@ -29,6 +31,7 @@ type TaskAuditItem = { const TaskAudit: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { q } = useAppContext(); const [items, setItems] = useState([]); const [selected, setSelected] = useState(null); @@ -36,7 +39,7 @@ const TaskAudit: React.FC = () => { const [sourceFilter, setSourceFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); const [dailyReport, setDailyReport] = useState(''); - const [reportDate, setReportDate] = useState(new Date().toISOString().slice(0,10)); + const [reportDate, setReportDate] = useState(localDateInputValue()); const [showDailyReport, setShowDailyReport] = useState(false); const fetchData = async () => { @@ -90,6 +93,31 @@ const TaskAudit: React.FC = () => { const taskAction = async (action: 'pause'|'retry'|'complete'|'ignore') => { if (!selected?.task_id) return; + if (action === 'pause') { + const ok = await ui.confirmDialog({ + title: t('taskPauseConfirmTitle'), + message: t('taskPauseConfirmMessage', { id: selected.task_id }), + confirmText: t('pauseTask'), + }); + if (!ok) return; + } + if (action === 'complete') { + const ok = await ui.confirmDialog({ + title: t('taskCompleteConfirmTitle'), + message: t('taskCompleteConfirmMessage', { id: selected.task_id }), + confirmText: t('completeTask'), + }); + if (!ok) return; + } + if (action === 'ignore') { + const ok = await ui.confirmDialog({ + title: t('taskIgnoreConfirmTitle'), + message: t('taskIgnoreConfirmMessage', { id: selected.task_id }), + danger: true, + confirmText: t('ignoreTask'), + }); + if (!ok) return; + } try { const url = `/webui/api/task_queue${q}`; const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, task_id: selected.task_id }) }); @@ -157,7 +185,7 @@ const TaskAudit: React.FC = () => { >
{it.task_id || `task-${idx + 1}`}
{it.channel || '-'} · {it.status} · attempts:{it.attempts || 1} · {it.duration_ms || 0}ms · retry:{it.retry_count || 0} · {it.source || '-'} · {it.provider || '-'} / {it.model || '-'}
-
{it.time}
+
{formatLocalDateTime(it.time)}
); })} @@ -188,7 +216,7 @@ const TaskAudit: React.FC = () => {
{t('session')}
{selected.session}
{t('provider')}
{selected.provider || '-'}
{t('model')}
{selected.model || '-'}
-
{t('time')}
{selected.time}
+
{t('time')}
{formatLocalDateTime(selected.time)}
@@ -213,7 +241,7 @@ const TaskAudit: React.FC = () => {
{t('lastPauseAt')}
-
{selected.last_pause_at || '-'}
+
{formatLocalDateTime(selected.last_pause_at)}
diff --git a/webui/src/pages/Tasks.tsx b/webui/src/pages/Tasks.tsx index e079ab9..1d347e2 100644 --- a/webui/src/pages/Tasks.tsx +++ b/webui/src/pages/Tasks.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAppContext } from '../context/AppContext'; +import { useUI } from '../context/UIContext'; type TaskItem = { id?: string; @@ -14,6 +15,7 @@ type TaskItem = { const Tasks: React.FC = () => { const { t } = useTranslation(); + const ui = useUI(); const { q } = useAppContext(); const [items, setItems] = useState([]); const [selected, setSelected] = useState(null); @@ -34,6 +36,17 @@ const Tasks: React.FC = () => { useEffect(() => { load(); }, [q]); const save = async (action: 'create' | 'update' | 'delete') => { + if (action === 'delete') { + const targetId = String(draft.id || '').trim(); + if (!targetId) return; + const ok = await ui.confirmDialog({ + title: t('taskDeleteConfirmTitle'), + message: t('taskDeleteConfirmMessage', { id: targetId }), + danger: true, + confirmText: t('delete'), + }); + if (!ok) return; + } const payload: any = { action }; if (action === 'create') payload.item = draft; if (action === 'update') { payload.id = draft.id; payload.item = { id: draft.id, content: draft.content }; } diff --git a/webui/src/utils/time.ts b/webui/src/utils/time.ts new file mode 100644 index 0000000..2772bd3 --- /dev/null +++ b/webui/src/utils/time.ts @@ -0,0 +1,49 @@ +type DateLike = string | number | Date | null | undefined; + +function parseDateLike(value: DateLike): Date | null { + if (value == null) return null; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + if (typeof value === 'number') { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + } + const s = String(value).trim(); + if (!s) return null; + + if (/^\d+$/.test(s)) { + const n = Number(s); + const ms = n > 1e12 ? n : n * 1000; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? null : d; + } + + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d; +} + +export function formatLocalDateTime(value: DateLike, fallback = '-'): string { + const d = parseDateLike(value); + if (!d) { + const raw = value == null ? '' : String(value).trim(); + return raw || fallback; + } + return d.toLocaleString(); +} + +export function formatLocalTime(value: DateLike, fallback = '--:--:--'): string { + const d = parseDateLike(value); + if (!d) { + const raw = value == null ? '' : String(value).trim(); + return raw || fallback; + } + return d.toLocaleTimeString(); +} + +export function localDateInputValue(base = new Date()): string { + const d = new Date(base.getTime()); + d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); + return d.toISOString().slice(0, 10); +} +