diff --git a/hexi/web/src/api/client.ts b/hexi/web/src/api/client.ts index 7846127..1f5fed6 100644 --- a/hexi/web/src/api/client.ts +++ b/hexi/web/src/api/client.ts @@ -58,6 +58,12 @@ export const hubPlugins = async () => await request('/hub/api/plugins') export const hubDashboard = async () => await request('/hub/api/dashboard') export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog') export const hubGroups = async () => await request('/hub/api/groups') +// 请求取消一个在跑的任务(协作式:任务体在下一个检查点收尾,状态走 SSE 回来) +export const hubTaskCancel = async (id: string) => await request(`/hub/api/tasks/${id}/cancel`, { method: 'POST' }) +// 从任务列表里删掉一条已结束的任务(在跑的服务端会拒绝) +export const hubTaskDismiss = async (id: string) => await request(`/hub/api/tasks/${id}/dismiss`, { method: 'POST' }) +// 清掉所有已结束的任务 +export const hubTasksClear = async () => await request('/hub/api/tasks/clear', { method: 'POST' }) export const hubPluginSetGlobal = async (id: string, body: any) => await request('/hub/api/plugins/' + encodeURIComponent(id) + '/global', { method: 'POST', body }) export const hubPluginSetGroup = async (id: string, gid: string, body: any) => @@ -160,6 +166,21 @@ export function hubLogStream(opts: { }) } +/** 任务槽 SSE:每帧都是全量任务快照(服务端按百分比桶节流后才推)。 */ +export function hubTasksStream(opts: { + onTasks: (tasks: any[]) => void + onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void +}): () => void { + return makeSSEStream({ + url: () => '/hub/api/tasks/stream', + onData: (obj) => { + if (obj && Array.isArray(obj.tasks)) opts.onTasks(obj.tasks) + }, + onStatus: opts.onStatus, + retryMs: 2000, + }) +} + export function hubDashboardStream(opts: { onData: (data: any) => void onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index 852ce0a..1ac633a 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -11,6 +11,7 @@ import { useAuth } from '../auth/auth' import { hubPlugins } from '../api/client' import { ErrorBoundary } from '../components/ErrorBoundary' import { useConnection } from '../connection' +import { TaskCenterButton } from '../tasks' import { BOT_ACTION_TEXT } from '../lib/botAction' import { BotActionOverlay, useBotAction } from '../botAction' @@ -197,6 +198,7 @@ export function AppLayout() { )}
+ = { + http: '协议端按 /media 链接来拉(推荐,跨机部署)', + base64: '字节内联在消息里(协议端拨不通本机时用,体积膨胀 1/3)', + file: '不改写、直接给本地路径(只有协议端就在本机时才正确)', +} const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none' export default function SettingsPage() { @@ -49,6 +57,7 @@ export default function SettingsPage() { const [cfgDebug, setCfgDebug] = useState(false) const [cfgLogLevel, setCfgLogLevel] = useState('INFO') const [cfgLogMaxMb, setCfgLogMaxMb] = useState('20') + const [cfgMediaMode, setCfgMediaMode] = useState('http') const [cfgBusy, setCfgBusy] = useState(false) const [cfgLoaded, setCfgLoaded] = useState(false) @@ -68,6 +77,7 @@ export default function SettingsPage() { setCfgDebug(!!c.debug) setCfgLogLevel(c.log_level || 'INFO') setCfgLogMaxMb(String(c.log_max_mb ?? c.log_max_mb_default ?? 20)) + setCfgMediaMode(c.media_mode || 'http') setCfgLoaded(true) } catch (e: any) { setCfgLoaded(false) @@ -103,6 +113,7 @@ export default function SettingsPage() { nickname: (cfgNickname || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean), debug: cfgDebug, log_level: cfgLogLevel, + media_mode: cfgMediaMode, } const logMaxMbRaw = cfgLogMaxMb.trim() if (logMaxMbRaw !== '') { @@ -117,6 +128,7 @@ export default function SettingsPage() { setCfgDebug(!!r.debug) setCfgLogLevel(r.log_level || 'INFO') setCfgLogMaxMb(String(r.log_max_mb ?? r.log_max_mb_default ?? 20)) + setCfgMediaMode(r.media_mode || 'http') toast.success('BOT 配置已保存') if (r.notice) toast.info(r.notice) } catch (e: any) { @@ -167,6 +179,7 @@ export default function SettingsPage() { SUPERUSERS / NICKNAME 支持逗号或空格分隔;保存后会写入 .env 并尽量热应用。 部分插件在启动时缓存了这些值,完全生效建议保存后重启 bot。 日志清理大小保存后仅在下一个检查周期(约 1 分钟)内生效,无需重启。 + 媒体发送方式保存后立即生效,无需重启。

@@ -203,6 +216,16 @@ export default function SettingsPage() { setCfgLogMaxMb(e.target.value)} placeholder="如: 20" /> _bot_run.log 超过该大小即清空(不归档) +
+ + + {MEDIA_MODE_HINTS[cfgMediaMode] || '图片/视频/文件的发送方式'} +
diff --git a/hexi/web/src/plugins/video_analysis/index.tsx b/hexi/web/src/plugins/video_analysis/index.tsx index 8edd8e4..729a40d 100644 --- a/hexi/web/src/plugins/video_analysis/index.tsx +++ b/hexi/web/src/plugins/video_analysis/index.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - Button, Card, Chip, Drawer, Input, Label, ListBox, Select, Spinner, Switch, TextArea, TextField, - Tooltip, TooltipContent, toast, useOverlayState, + Button, Card, Chip, Drawer, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextArea, + TextField, Tooltip, TooltipContent, toast, useOverlayState, } from '@heroui/react' import { ArrowDownToLine, ArrowUpRightFromSquare, CirclePlay, TrashBin, Xmark } from '@gravity-ui/icons' import Lightbox from 'yet-another-react-lightbox' @@ -16,7 +16,8 @@ import 'yet-another-react-lightbox/styles.css' import 'yet-another-react-lightbox/plugins/captions.css' import 'yet-another-react-lightbox/plugins/counter.css' import 'yet-another-react-lightbox/plugins/thumbnails.css' -import { api, hubGroups } from '../../api/client' +import { api, hubGroups, hubTaskCancel } from '../../api/client' +import { taskOrigin, useTasks } from '../../tasks' import { PageHead, Tabs, selectCls, tdCls, thCls, type TabItem } from '../../components/ui' import { fmtBytes, fmtTime } from '../../lib/format' @@ -140,10 +141,11 @@ function toSlide(file: JobFile, src: string): Slide { return { src, alt: file.name, title } } -type MainTab = 'parse' | 'policy' +type MainTab = 'parse' | 'policy' | 'temp' const MAIN_TABS: TabItem[] = [ { key: 'parse', label: '链接解析' }, { key: 'policy', label: '群策略' }, + { key: 'temp', label: '缓存' }, ] export default function VideoAnalysisPage() { @@ -164,6 +166,7 @@ export default function VideoAnalysisPage() {
{tab === 'parse' && } {tab === 'policy' && } + {tab === 'temp' && }
) @@ -504,6 +507,253 @@ function BlacklistCard({ ov, onSaved }: { ov: Overview; onSaved: () => Promise(null) + const [days, setDays] = useState('7') + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const wasRunning = useRef(false) + const confirmState = useOverlayState() + // 任务状态来自顶栏任务的全局 provider(同一条 SSE,别自己再订阅一条) + const { tasks } = useTasks() + const task = useMemo( + () => tasks.find(t => t.slot === CLEANUP_SLOT) || null, + [tasks], + ) + + const load = useCallback(async () => { + setLoading(true) + try { + const d: TempStats = data(await va.get('temp/stats')) + setSt(d) + setDays(String(d?.max_age_days ?? 7)) + } catch (e: any) { + toast.danger(e.message || '缓存信息加载失败') + } finally { setLoading(false) } + }, []) + + useEffect(() => { void load() }, [load]) + + // 「运行中 → 终态」这一次跳变才刷统计 + 弹提示(页面打开时带出的历史终态任务 + // 不该再弹一次 toast,所以只认跳变)。 + useEffect(() => { + const running = !!task?.active + if (wasRunning.current && !running && task) { + void load() + const removed = (task.result as [number, number] | undefined)?.[0] ?? 0 + if (task.status === 'failed') toast.danger(`清理失败:${task.error || '未知错误'}`) + else if (task.status === 'canceled') toast.info(`清理已取消(已删除 ${removed} 个)`) + else toast.success(`已删除 ${removed} 个文件`) + } + wasRunning.current = running + }, [task, load]) + + const clean = async (rawDays: number) => { + setBusy(true) + try { + const d = data(await va.post('temp/cleanup', { days: rawDays })) + // 任务状态由全局 SSE 推来(这里不再本地维护一份),提交成功即视为已开始 + if (d?.task) wasRunning.current = true + if (d?.reused) toast.info('已有清理任务在运行,进度见下方') + else toast.success('清理已开始,进度实时更新') + } catch (e: any) { + toast.danger(e.message || '清理提交失败') + } finally { setBusy(false) } + } + + const cleanByDays = () => { + const n = parseInt(days, 10) + if (!(n >= 0)) { toast.warning('保留天数需为不小于 0 的整数'); return } + void clean(n) + } + + const confirmClear = async () => { + confirmState.close() + await clean(0) + } + + const cancelTask = async () => { + if (!task) return + try { + await hubTaskCancel(task.id) + toast.info('已请求取消,任务会在当前文件处理完后收尾') + } catch (e: any) { + toast.danger(e.message || '取消失败') + } + } + + const isRunning = !!task?.active + const p = task?.progress + + // 正在写入的文件 mtime 很新,两种清理都不会碰(见 cleanup.py 的 60s 目录下限) + const autoText = st + ? st.mode === 'manual' + ? '自动清理:已关闭(manual,仅手动清理)' + : `自动清理:每天 ${st.hour}:00 清理 ${st.max_age_days} 天前的文件` + : '自动清理:读取中…' + + return ( +
+ + + + 缓存占用 + + hexi/data/temp —— 下载的中转区,发送后仍保留供取用(含各插件的子目录) + + + + +
+ {([['文件', st ? String(st.files) : '—'], + ['占用', st ? fmtBytes(st.bytes) : '—'], + ['目录', st ? String(st.dirs) : '—']] as const).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ {autoText}(在插件配置页可改) + +
+
+
+ + + + + 手动清理 + + 正在写入的文件不会被误删;空目录会被一并收掉 + + + + +
+
+ + + setDays(e.target.value)} /> + +
+ + +
+ + {task && ( +
+
+ + {task.title} + {p?.phase ? ` · ${p.phase}` : ''} + + {taskOrigin(task)} + {task.actor ? ` · ${task.actor}` : ''} + + + + {task.active + ? `${p?.percent == null ? '…' : p.percent + '%'}${p?.total ? ` (${p.done}/${p.total}${p.unit})` : ''} · 已运行 ${task.duration.toFixed(0)}s` + : task.status === 'done' + ? `完成 · 用时 ${task.duration.toFixed(0)}s` + : task.status === 'canceled' ? '已取消' : '失败'} + +
+
+
+
+
+ + {task.status === 'failed' + ? task.error || '未知错误' + : task.status === 'canceled' + ? `已删除 ${(task.result as [number, number] | undefined)?.[0] ?? 0} 个后取消` + : p?.message || ''} + + {task.active && ( + + )} +
+ {task.warnings.length > 0 && ( +
+ + {task.warnings.length} 个文件被跳过(点击看详情) + +
    + {task.warnings.map((w, i) =>
  • {w}
  • )} +
+
+ )} +
+ )} + +

+ 等效于群里的「/清理temp [天数]」(需管理权限);保留 0 天 = 全部清空。 + 清理在后台线程跑,页面其它操作不受影响。 +

+ + + + + + 全部清空缓存 + +

+ 将删除 temp 目录下全部 {st?.files ?? 0} 个文件({st ? fmtBytes(st.bytes) : '—'})。 + 已归档到 S3 的媒体不受影响,但 temp 里这份就没有了 —— 页面上的预览链接会失效。 +

+
+ + + + +
+
+
+ ) +} + + /** 链接解析工作区:左栏「输入 + 任务列表」,右栏播放器。 * * 存储按**默认策略**走(不是某个群的策略),所以这里没有群号可选。 diff --git a/hexi/web/src/router.tsx b/hexi/web/src/router.tsx index fa733ae..2cdcf51 100644 --- a/hexi/web/src/router.tsx +++ b/hexi/web/src/router.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from './components/ErrorBoundary' import { ConnectionProvider } from './connection' import { LogProvider } from './logs' import { BotActionProvider } from './botAction' +import { TasksProvider } from './tasks' import { pluginModules } from './plugins' import HomePage from './pages/home' import PluginsPage from './pages/plugins' @@ -23,7 +24,7 @@ export function AppRoutes() { } /> } /> - }> + }> } /> } /> } /> diff --git a/hexi/web/src/tasks.tsx b/hexi/web/src/tasks.tsx new file mode 100644 index 0000000..f44b8d8 --- /dev/null +++ b/hexi/web/src/tasks.tsx @@ -0,0 +1,380 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { Button, Drawer, Spinner, toast, useOverlayState } from '@heroui/react' +import { CircleStop, ListCheck, TrashBin } from '@gravity-ui/icons' +import { hubTaskCancel, hubTaskDismiss, hubTasksClear, hubTasksStream } from './api/client' + +/** 任务槽里的一条任务(后端 hexi.core.task_slots 的 Task.to_dict())。 */ +export interface SlotTask { + id: string + slot: string + title: string + plugin_id: string + trigger: string + actor: string + status: 'running' | 'done' | 'failed' | 'canceled' + active: boolean + cancel_requested: boolean + progress: { + percent: number | null + done: number + total: number + unit: string + phase: string + message: string + } + result?: unknown + warnings: string[] + error: string + created_at: number + started_at: number + finished_at: number + duration: number +} + +const TRIGGER_LABEL: Record = { web: '网页', command: '群命令', schedule: '定时' } + +/** 任务来源的一句话:`网页 · admin` / `群命令 · 12345`。 */ +export function taskOrigin(t: SlotTask): string { + const kind = TRIGGER_LABEL[t.trigger] || t.trigger || '未知来源' + return t.actor ? `${kind} · ${t.actor}` : kind +} + +/** 进度百分比文案:总数未知时给阶段名,别显示 0% 骗人。 */ +export function taskPercentText(t: SlotTask): string { + const p = t.progress + if (t.active) return p.percent == null ? (p.phase || '进行中') : `${p.percent}%` + if (t.status === 'done') return '完成' + if (t.status === 'canceled') return '已取消' + return '失败' +} + +interface TasksState { + tasks: SlotTask[] + running: SlotTask[] + finished: SlotTask[] + status: 'connecting' | 'open' | 'reconnecting' | 'closed' +} + +const Ctx = createContext({ + tasks: [], running: [], finished: [], status: 'connecting', +}) + +/** + * 全局任务中心数据源:常驻一条 /hub/api/tasks/stream SSE。 + * + * 顶栏徽章与抽屉共用同一份数据,所以必须放在 provider 里(各页面自己订阅会开 + * 多条 SSE)。与 connection.tsx 同款做法。 + */ +export function TasksProvider({ children }: { children: ReactNode }) { + const [tasks, setTasks] = useState([]) + const [status, setStatus] = useState('connecting') + + useEffect(() => { + const dispose = hubTasksStream({ + onTasks: (list: SlotTask[]) => { setTasks(list || []); setStatus('open') }, + onStatus: (s) => setStatus(s === 'open' ? 'open' : s === 'reconnecting' ? 'reconnecting' : 'closed'), + }) + return dispose + }, []) + + const value = useMemo(() => ({ + tasks, + running: tasks.filter(t => t.active), + finished: tasks.filter(t => !t.active), + status, + }), [tasks, status]) + + return {children} +} + +export const useTasks = () => useContext(Ctx) + +/** + * 顶栏「任务中心」按钮 + 抽屉。 + * + * 有任务在跑时按钮上挂数字徽章(不弹 toast 打扰,用户自己点开看)。 + * 在跑的只能取消(删掉就没人回传状态了),已结束的可以逐条删除 / 一键清空。 + */ +export function TaskCenterButton() { + const { tasks, status } = useTasks() + const drawer = useOverlayState() + const [dealId, setDealId] = useState('') + // 点了删除、等 SSE 确认的 id(行变灰 + 按钮禁用) + const [pending, setPending] = useState([]) + // 服务端已确认消失、正在播退场动画的快照 + const [leaving, setLeaving] = useState([]) + // 上一帧的 tasks:用来在渲染期发现"谁被服务端删掉了" + const [seen, setSeen] = useState([]) + const scheduled = useRef>(new Set()) + + const aliveIds = useMemo(() => new Set(tasks.map(t => t.id)), [tasks]) + const pendingIds = useMemo(() => new Set(pending), [pending]) + + // 渲染期对齐(React 官方推荐的"props 变了就同步调状态"模式): + // **退场动画只能在服务端确认消失之后播** —— 点击就播的话,动画放完服务端还没删完, + // 行会闪回来、等 SSE 到了再瞬间消失,比不做动画还难受。所以这里等它真的从 tasks + // 里消失,才把快照挪进 leaving(同一帧内换处理,DOM 节点不重建 → CSS 过渡才跑得起来)。 + if (tasks !== seen) { + const gone = seen.filter(t => !aliveIds.has(t.id) && pendingIds.has(t.id)) + setSeen(tasks) + if (gone.length) { + setLeaving(prev => [...prev, ...gone.filter(t => !prev.some(x => x.id === t.id))]) + setPending(prev => prev.filter(id => !gone.some(t => t.id === id))) + } + } + + // 退场动画播完就把快照丢掉(每个快照只排一次定时器) + useEffect(() => { + leaving.forEach((t, i) => { + if (scheduled.current.has(t.id)) return + scheduled.current.add(t.id) + window.setTimeout(() => { + scheduled.current.delete(t.id) + setLeaving(prev => prev.filter(x => x.id !== t.id)) + }, LEAVE_MS + Math.min(i * 30, 150)) + }) + }, [leaving]) + + // 兜底:请求成功了但 SSE 一直没确认(比如连接断了)→ 8 秒后解除"删除中",别让行永远灰着 + useEffect(() => { + if (!pending.length) return + const ids = pending + const timer = window.setTimeout(() => { + setPending(prev => prev.filter(x => !ids.includes(x))) + toast.warning('服务端还没确认删除,稍后刷新看看') + }, 8000) + return () => window.clearTimeout(timer) + }, [pending]) + + // 服务端已经删掉的,靠 leaving 里的快照继续渲染到动画结束 + const visible = useMemo(() => { + const ids = new Set(tasks.map(t => t.id)) + return [...tasks, ...leaving.filter(t => !ids.has(t.id))] + }, [tasks, leaving]) + const running = visible.filter(t => t.active) + const finished = visible.filter(t => !t.active) + const leavingIds = useMemo(() => new Set(leaving.map(t => t.id)), [leaving]) + const leaveDelay = useMemo(() => { + const m = new Map() + leaving.forEach((t, i) => m.set(t.id, Math.min(i * 30, 150))) + return m + }, [leaving]) + + const cancel = async (t: SlotTask) => { + setDealId(t.id) + try { + await hubTaskCancel(t.id) + toast.info('已请求取消,任务会在下一个检查点收尾') + } catch (e: any) { + toast.danger(e.message || '取消失败') + } finally { setDealId('') } + } + + const dismiss = async (t: SlotTask) => { + setPending(prev => [...new Set([...prev, t.id])]) // 先变灰,等服务端确认再滑走 + try { + await hubTaskDismiss(t.id) // 列表由 SSE 刷新,本地不用改 + } catch (e: any) { + setPending(prev => prev.filter(x => x !== t.id)) // 失败:恢复可点 + toast.danger(e.message || '删除失败') + } + } + + const clearFinished = async () => { + const ids = finished.map(t => t.id) + setPending(prev => [...new Set([...prev, ...ids])]) + try { + const d = await hubTasksClear() + toast.success(`已清理 ${d?.removed ?? 0} 条结束的任务`) + } catch (e: any) { + setPending(prev => prev.filter(x => !ids.includes(x))) + toast.danger(e.message || '清理失败') + } + } + + return ( + <> + + + + + + + + + 任务中心 + + {status === 'open' + ? `${running.length} 个进行中 · ${finished.length} 个已结束` + : status === 'reconnecting' ? '重连中…' : '已断开'} + + + + + + {tasks.length === 0 ? ( +

+ {status === 'open' ? '暂无任务。长任务(清缓存、批量下载等)会出现在这里。' : '正在连接任务流…'} +

+ ) : ( +
+ {running.length > 0 && ( +
+
进行中
+ {running.map(t => ( + + + + ))} +
+ )} + {finished.length > 0 && ( +
+
+ 已结束 + +
+ {finished.map(t => ( + + + + ))} +
+ )} +
+ )} +
+
+
+
+
+ + ) +} + +/** 退场动画时长(毫秒),与 `beginLeave` 的定时器对齐。 */ +const LEAVE_MS = 200 + +/** + * 列表项的退场动画:左滑淡出 + 高度收起。 + * + * HeroUI v3 没有 exit 动画原语(88 个组件里只有 accordion/disclosure 那种展开 + * 折叠,也没有 framer-motion),而任务列表是**服务端驱动的**——不能靠卸载触发 + * 动画,所以由 `leaving` 快照多留一帧,这里用 CSS 过渡演:外层 grid 的 + * `grid-rows` 1fr→0fr 负责收高度(不用猜 max-height),内层 overflow-hidden。 + */ +function Collapsible({ + leaving, delayMs = 0, children, +}: { + leaving: boolean + delayMs?: number + children: ReactNode +}) { + return ( +
+
+
{children}
+
+
+ ) +} + + +/** 抽屉里的一行:标题 + 来源 + 进度条 + 状态;按状态给「取消」或「删除」。 */ +function TaskRow({ + t, busy, dimmed = false, onCancel, onDismiss, +}: { + t: SlotTask + busy: boolean + /** 已点删除、等服务端确认(变灰 + 禁按钮,不播动画) */ + dimmed?: boolean + onCancel?: (t: SlotTask) => void + onDismiss?: (t: SlotTask) => void +}) { + const p = t.progress + const barCls = t.status === 'failed' + ? 'bg-red-400' + : t.status === 'canceled' ? 'bg-slate-300' + : t.status === 'done' ? 'bg-emerald-500' : 'bg-indigo-500' + + return ( +
+
+ {t.title} + {dimmed ? '删除中…' : taskPercentText(t)} +
+
+ + {taskOrigin(t)} + {p.phase ? ` · ${p.phase}` : ''} + {t.active && t.duration ? ` · 已运行 ${t.duration.toFixed(0)}s` : ''} + + + {onCancel && t.active && ( + + )} + {onDismiss && !t.active && ( + + )} + +
+
+
+
+ {(p.message || t.error) && ( +
+ {t.status === 'failed' ? (t.error || '未知错误') : p.message} +
+ )} + {t.warnings.length > 0 && ( +
+ {t.warnings.length} 条警告 +
    + {t.warnings.map((w, i) =>
  • {w}
  • )} +
+
+ )} +
+ ) +} + +/** 抽屉里的加载态(预留给以后按需拉取列表的场景)。 */ +export function TaskCenterLoading() { + return
+} diff --git a/hexi/web/tsconfig.tsbuildinfo b/hexi/web/tsconfig.tsbuildinfo index 78b718d..7c46bb4 100644 --- a/hexi/web/tsconfig.tsbuildinfo +++ b/hexi/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/botaction.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/botaction.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/tasks.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/bf_bot/index.tsx","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/hexi/web_hub/__init__.py b/hexi/web_hub/__init__.py index ca3faf4..c2578ca 100644 --- a/hexi/web_hub/__init__.py +++ b/hexi/web_hub/__init__.py @@ -41,6 +41,7 @@ from .web_plugin_registry import get_web_plugin_builder, list_web_plugins from .dashboard import collect_dashboard + # hexi/web_hub 为机器人核心模块(统一 Web 管理台),非 NoneBot 插件 basic_path = Path(__file__).resolve().parent # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web @@ -333,6 +334,10 @@ async def _first_bot(): def build_hub_app() -> FastAPI: + # 延迟导入:hexi.core 是插件侧基础设施,模块级导入会触发它整套初始化 + # (见本模块文档"对 core 的引用一律函数内导入"的约定) + from hexi.core.task_slots import TASKS + sync_admin() app = FastAPI(title="HeXi Web Hub") @@ -575,6 +580,58 @@ def build_hub_app() -> FastAPI: return StreamingResponse(gen(), media_type="text/event-stream") + @app.get("/api/tasks") + async def list_tasks(_: dict = Depends(get_current_user)): + """任务槽的当前快照(新的在前);SSE 首帧同款,作轮询兜底用。""" + return {"ok": True, "tasks": TASKS.snapshot()} + + @app.get("/api/tasks/stream") + async def tasks_stream(_: dict = Depends(get_current_user)): + """任务槽 SSE:连上先发一帧全量快照,之后每次任务变化再发一帧。 + + 变化不频繁(按百分比桶节流),所以直接推送全量快照,不做增量协议。 + """ + queue = TASKS.subscribe() + + async def gen(): + try: + yield f"data: {json.dumps({'ok': True, 'tasks': TASKS.snapshot()}, ensure_ascii=False)}\n\n" + while True: + try: + tasks = await asyncio.wait_for(queue.get(), timeout=15) + except asyncio.TimeoutError: + yield ": ping\n\n" # ~15s 心跳保活 + continue + yield f"data: {json.dumps({'ok': True, 'tasks': tasks}, ensure_ascii=False)}\n\n" + finally: + TASKS.unsubscribe(queue) + + return StreamingResponse(gen(), media_type="text/event-stream") + + @app.post("/api/tasks/clear") + async def clear_tasks(_: dict = Depends(get_current_user)): + """清掉所有已结束的任务(在跑的不动)。""" + removed = TASKS.clear_finished() + return {"ok": True, "removed": removed} + + @app.post("/api/tasks/{task_id}/dismiss") + async def dismiss_task(task_id: str, _: dict = Depends(get_current_user)): + """从列表里删掉一条已结束的任务(在跑的先取消,删不了)。""" + if not TASKS.dismiss(task_id): + return {"ok": False, "msg": "任务不存在,或还在运行(在跑的只能先取消)"} + return {"ok": True} + + @app.post("/api/tasks/{task_id}/cancel") + async def cancel_task(task_id: str, _: dict = Depends(get_current_user)): + """请求取消一个在跑的任务。 + + 协作式:只置标志,任务体在下一个检查点收尾(见 task_slots 模块文档), + 所以接口马上返回不代表任务已经停了 —— 状态变化会从 SSE 推出去。 + """ + if not TASKS.cancel(task_id): + return {"ok": False, "msg": "任务不存在或已结束"} + return {"ok": True} + @app.get("/api/logs/stream") async def logs_stream( since: int = 0, @@ -726,4 +783,4 @@ async def _startup_mount_web_plugins() -> None: @get_driver().on_startup async def _downgrade_uvicorn_access_logs() -> None: """把 uvicorn.access 日志降级为 DEBUG(经 loguru 后按日志等级过滤)。""" - _logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug) \ No newline at end of file + _logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug) diff --git a/hexi/web_hub/bot_settings.py b/hexi/web_hub/bot_settings.py index 9434f25..60341c2 100644 --- a/hexi/web_hub/bot_settings.py +++ b/hexi/web_hub/bot_settings.py @@ -32,6 +32,10 @@ _KEY_ENV: dict[str, str] = { # LOG_LEVEL 可选项(写入时建议大写) LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +# 资源发送方式(写入 .env 的 HEXI_MEDIA_MODE;语义见 hexi/core/outbound_media._mode) +MEDIA_MODE_OPTIONS = ["http", "base64", "file"] +_MEDIA_MODE_ENV = "HEXI_MEDIA_MODE" + # 日志清理大小(MB)默认值(未在 .env 配置时) _LOG_MAX_MB_DEFAULT = 20 @@ -103,6 +107,22 @@ def _current_log_max_mb() -> int: return _LOG_MAX_MB_DEFAULT +def _current_media_mode() -> str: + """当前资源发送方式(HEXI_MEDIA_MODE > 旧的两个开关 > 默认 http)。 + + 推断规则必须与 `hexi.core.outbound_media._mode` 一致(那边运行时读 + os.environ,这边多读一层 .env 文件,好在手改 .env 未重启时也能显示正确)。 + """ + raw = _env_get(_MEDIA_MODE_ENV).strip().lower() + if raw in MEDIA_MODE_OPTIONS: + return raw + if _env_get("HEXI_INLINE_MEDIA").strip().lower() in ("0", "false", "no", "off"): + return "file" + if _env_get("HEXI_INLINE_MEDIA_URL").strip().lower() in ("0", "false", "no", "off"): + return "base64" + return "http" + + def current_values() -> dict[str, Any]: """返回当前生效值(读取优先级:driver.config > .env)。""" try: @@ -129,6 +149,8 @@ def current_values() -> dict[str, Any]: "log_level_options": LOG_LEVEL_OPTIONS, "log_max_mb": _current_log_max_mb(), "log_max_mb_default": _LOG_MAX_MB_DEFAULT, + "media_mode": _current_media_mode(), + "media_mode_options": MEDIA_MODE_OPTIONS, "source": "config" if cfg is not None else "env", } @@ -190,6 +212,14 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: if log_max_mb <= 0: raise ValueError("日志清理大小必须是正整数(MB)") + # 资源发送方式(http/base64/file,写入 HEXI_MEDIA_MODE) + media_mode: str | None = None + _media_mode_raw = values.get("media_mode") + if _media_mode_raw is not None and str(_media_mode_raw).strip() != "": + media_mode = str(_media_mode_raw).strip().lower() + if media_mode not in MEDIA_MODE_OPTIONS: + raise ValueError(f"资源发送方式必须是 {'/'.join(MEDIA_MODE_OPTIONS)} 之一") + # 1. 写 os.environ(即时可读,进程内 getenv 立刻生效) os.environ["SUPERUSERS"] = _serialize_list(superusers) os.environ["NICKNAME"] = _serialize_list(nickname) @@ -197,6 +227,9 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: os.environ["LOG_LEVEL"] = log_level if log_max_mb is not None: os.environ["HEXI_LOG_MAX_MB"] = str(log_max_mb) + if media_mode is not None: + # outbound_media._mode() 每次发送现读 os.environ → 保存即生效,无需重启 + os.environ[_MEDIA_MODE_ENV] = media_mode # 2. 落盘 .env(重启后依然生效) _set_env_line("SUPERUSERS", _serialize_list(superusers)) @@ -205,6 +238,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: _set_env_line("LOG_LEVEL", log_level) if log_max_mb is not None: _set_env_line("HEXI_LOG_MAX_MB", str(log_max_mb)) + if media_mode is not None: + _set_env_line(_MEDIA_MODE_ENV, media_mode) # 3. 热应用到 driver.config applied: list[str] = [] @@ -232,6 +267,8 @@ def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]: _apply_log_level(log_level) if log_max_mb is not None: applied.append("HEXI_LOG_MAX_MB") + if media_mode is not None: + applied.append(_MEDIA_MODE_ENV) return { "ok": True, diff --git a/hexi/web_hub/dashboard.py b/hexi/web_hub/dashboard.py index 4ce87d1..fc0bb25 100644 --- a/hexi/web_hub/dashboard.py +++ b/hexi/web_hub/dashboard.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import os import platform +import socket import time import psutil @@ -216,8 +217,26 @@ def _is_private_ip(ip: str) -> bool: return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31) +def _route_ip() -> str | None: + """默认路由出口 IP —— 连外网时内核会挑的那张网卡。 + + VPN / 虚拟机 / WSL / EasyTier 的地址同样是私有段,纯按字符串排序会让 + 虚拟网卡赢(10.0.0.129 < 192.168.2.186),首页「协议端配对」就会展示一个 + 协议端连不上的地址(2026-10-08 实测)。逻辑与 hexi.core.outbound_media + 的 `_route_ip` 同源,改这里记得同步那边;无网络时返回 None。 + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("8.8.8.8", 80)) + return str(sock.getsockname()[0]) + except OSError: + return None + finally: + sock.close() + + def _local_ips() -> list[str]: - """本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。""" + """本机可被协议端访问的 IPv4 列表(默认路由网卡最前,其次私有网段优先)。""" ips: list[str] = [] try: addrs = psutil.net_if_addrs() @@ -237,7 +256,12 @@ def _local_ips() -> list[str]: continue if ip not in ips: ips.append(ip) - return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip)) + ips.sort(key=lambda ip: (not _is_private_ip(ip), ip)) + route = _route_ip() + if route in ips: + ips.remove(route) + ips.insert(0, route) + return ips def _ws_path() -> str: