diff --git a/backend/api-server/middleware/execution-lock.js b/backend/api-server/middleware/execution-lock.js new file mode 100644 index 00000000..7c795f30 --- /dev/null +++ b/backend/api-server/middleware/execution-lock.js @@ -0,0 +1,170 @@ +/** + * 执行锁中间件 · L2 API 执行拦截层 + * + * 当一个操作开始执行时: + * 1. 为该操作创建一个执行上下文(executionId) + * 2. 锁定相关资源(防止并发冲突) + * 3. 拒绝一切 cancel/abort/rollback 请求 + * 4. 执行完成后自动释放锁 + * + * 同一个开发者不能同时执行多个写入操作。 + * 版权:国作登字-2026-A-00037559 + */ + +'use strict'; + +var crypto = require('crypto'); + +// 内存中的执行锁表 +var activeLocks = new Map(); + +function generateExecutionId() { + return 'exec-' + Date.now() + '-' + crypto.randomBytes(4).toString('hex'); +} + +/** + * 获取执行锁表引用(供看门狗使用) + */ +function getActiveLocks() { + return activeLocks; +} + +/** + * 创建执行锁 + * @param {string} devId - 开发者编号 + * @param {string} operation - 操作描述 + * @returns {Object} { success, executionId, lock } 或 { success: false, reply } + */ +function acquireLock(devId, operation) { + if (activeLocks.has(devId)) { + var existing = activeLocks.get(devId); + if (existing.state === 'running') { + return { + success: false, + reply: '⏳ 你有一个操作正在执行中(' + existing.operation + + '),请等待完成。\n\n系统不允许同时执行多个操作——这是为了保护数据一致性。' + }; + } + } + + var executionId = generateExecutionId(); + var lock = { + executionId: executionId, + devId: devId, + operation: operation, + startTime: Date.now(), + state: 'running', + steps: [], + checkpoints: [], + warned: false, + completedSteps: 0, + totalSteps: 0, + result: null, + error: null + }; + + activeLocks.set(devId, lock); + + return { + success: true, + executionId: executionId, + lock: lock + }; +} + +/** + * 释放执行锁 + */ +function releaseLock(devId) { + activeLocks.delete(devId); +} + +/** + * 获取执行状态(通过 executionId) + */ +function getExecutionStatus(executionId) { + for (var entry of activeLocks) { + if (entry[1].executionId === executionId) { + return entry[1]; + } + } + return null; +} + +/** + * 记录执行步骤(用于事后回放) + */ +function logStep(devId, step) { + var lock = activeLocks.get(devId); + if (lock) { + lock.steps.push({ + message: step.message, + success: step.success, + timestamp: new Date().toISOString() + }); + if (step.success) { + lock.completedSteps++; + } + } +} + +/** + * 保存检查点(用于失败时回滚) + */ +function saveCheckpoint(devId, checkpoint) { + var lock = activeLocks.get(devId); + if (lock) { + lock.checkpoints.push({ + id: checkpoint.id, + label: checkpoint.label, + data: checkpoint.data, + timestamp: new Date().toISOString() + }); + } +} + +/** + * 取消拦截中间件 + * 拦截所有试图取消执行中操作的请求 + */ +function blockCancellation(req, res, next) { + var devId = req.user ? req.user.devId : null; + if (!devId) return next(); + + var isCancelAttempt = + req.path.includes('/cancel') || + req.path.includes('/abort') || + req.path.includes('/rollback'); + + var isDeleteOnLocked = req.method === 'DELETE' && activeLocks.has(devId); + + if (isCancelAttempt || isDeleteOnLocked) { + var lock = activeLocks.get(devId); + if (lock && lock.state === 'running') { + return res.status(423).json({ + error: true, + code: 'EXECUTION_LOCKED', + locked: true, + executionId: lock.executionId, + reply: '🔒 操作「' + lock.operation + '」正在执行中,不可取消。\n\n' + + '系统已接收你的指令并开始执行。这条执行链路涉及多个自动化流程的联动,' + + '中途中断会导致数据不一致和级联故障。\n\n' + + '⏳ 请等待执行完成。完成后你会看到完整的执行结果。\n\n' + + '如果你对结果不满意,可以说「前面的没想好,我们重新来」——' + + '系统会在已有结果的基础上重新迭代,而不是撤回已执行的操作。' + }); + } + } + + next(); +} + +module.exports = { + acquireLock: acquireLock, + releaseLock: releaseLock, + getExecutionStatus: getExecutionStatus, + getActiveLocks: getActiveLocks, + logStep: logStep, + saveCheckpoint: saveCheckpoint, + blockCancellation: blockCancellation +}; diff --git a/backend/api-server/routes/execution.js b/backend/api-server/routes/execution.js new file mode 100644 index 00000000..96e75b04 --- /dev/null +++ b/backend/api-server/routes/execution.js @@ -0,0 +1,84 @@ +/** + * 执行状态查询路由 · L2 API 层 + * + * GET /api/execution/:executionId/status — 前端轮询执行状态 + * POST /api/execution/:executionId/cancel — 明确拒绝取消(403) + * DELETE /api/execution/:executionId — 明确拒绝删除(403) + * + * 注意:没有取消接口。这不是遗漏,是设计。 + * 版权:国作登字-2026-A-00037559 + */ + +'use strict'; + +var express = require('express'); +var router = express.Router(); +var executionLock = require('../middleware/execution-lock'); +var authMiddleware = require('../middleware/auth'); + +router.use(authMiddleware.requireAuth); + +// 查询执行状态(前端轮询用) +router.get('/:executionId/status', function(req, res) { + var status = executionLock.getExecutionStatus(req.params.executionId); + + if (!status) { + return res.json({ + state: 'not_found', + reply: '未找到该执行记录。可能已经完成。' + }); + } + + // 计算进度百分比 + var progress = 5; + if (status.totalSteps > 0) { + progress = Math.min(95, Math.round((status.completedSteps / status.totalSteps) * 100)); + } else if (status.steps.length > 0) { + progress = Math.min(95, status.steps.length * 15); + } + + var afterIdx = parseInt(req.query.after, 10) || 0; + + res.json({ + executionId: status.executionId, + state: status.state, + operation: status.operation, + progress: progress, + currentStep: status.steps.length > 0 + ? status.steps[status.steps.length - 1].message + : '初始化执行环境…', + completedSteps: status.steps.filter(function(s) { return s.success; }).map(function(s) { + return { message: s.message, timestamp: s.timestamp }; + }), + newLogs: status.steps.slice(afterIdx), + startTime: new Date(status.startTime).toISOString(), + elapsed: Date.now() - status.startTime, + result: status.state === 'completed' ? status.result : undefined, + error: status.state === 'failed' ? status.error : undefined + }); +}); + +// 明确拒绝取消执行的请求 +router.post('/:executionId/cancel', function(_req, res) { + res.status(403).json({ + error: true, + code: 'CANCEL_FORBIDDEN', + reply: '🔒 执行中的操作不可取消。\n\n' + + '系统已开始执行,90+ 个自动化流程正在联动。' + + '中途中断会导致数据不一致和级联故障。\n\n' + + '请等待执行完成。如果结果不理想,你可以说:\n' + + '「前面的我没想好,我们重新来」\n' + + '系统会在已有基础上重新迭代。' + }); +}); + +// 明确拒绝删除执行记录 +router.delete('/:executionId', function(_req, res) { + res.status(403).json({ + error: true, + code: 'DELETE_FORBIDDEN', + reply: '🔒 不可删除执行记录。所有执行都是永久记录。' + }); +}); + +module.exports = router; diff --git a/backend/api-server/server.js b/backend/api-server/server.js index 43ca05d4..f46d225a 100644 --- a/backend/api-server/server.js +++ b/backend/api-server/server.js @@ -2,7 +2,7 @@ * 🔗 光湖后端中间层 · 通道B 入口 * * 实时调用 Notion API / GitHub API,为前端提供数据代理。 - * 包含:读取层 + 写入层 + 意图路由 + 权限沙箱 + 认知引导 + * 包含:读取层 + 写入层 + 意图路由 + 权限沙箱 + 认知引导 + 执行保护 * 只监听 127.0.0.1,通过 Nginx 反向代理对外暴露。 * * 版权:国作登字-2026-A-00037559 @@ -12,6 +12,8 @@ const express = require('express'); const cors = require('cors'); +const executionLock = require('./middleware/execution-lock'); +const watchdog = require('./services/execution-watchdog'); const app = express(); @@ -27,6 +29,9 @@ app.use(cors({ app.use(express.json()); +// 执行锁取消拦截(全局中间件,在所有路由之前) +app.use(executionLock.blockCancellation); + // 读取类路由(公开,无需认证) app.use('/api', require('./routes/health')); app.use('/api', require('./routes/dev')); @@ -40,15 +45,18 @@ app.use('/api', require('./routes/write')); // 认知引导 + 工具列表路由 app.use('/api', require('./routes/onboarding')); +// 执行状态查询路由(需认证) +app.use('/api/execution', require('./routes/execution')); + // 根路由 app.get('/', function(_req, res) { res.json({ status: 'ok', service: 'guanghu-api-server', - version: '2.0.0', + version: '3.0.0', channel: 'B', description: '光湖后端中间层 · 语言驱动操作系统', - capabilities: ['read', 'write', 'intent-routing', 'permission-sandbox', 'onboarding'] + capabilities: ['read', 'write', 'intent-routing', 'permission-sandbox', 'onboarding', 'execution-guard'] }); }); @@ -56,6 +64,9 @@ const PORT = process.env.PORT || 3001; app.listen(PORT, '127.0.0.1', function() { console.log('🔗 光湖后端中间层启动 · 端口 ' + PORT); console.log(' 通道B · 语言驱动操作系统'); - console.log(' 能力:读取 + 写入 + 意图路由 + 权限沙箱 + 认知引导'); + console.log(' 能力:读取 + 写入 + 意图路由 + 权限沙箱 + 认知引导 + 执行保护'); console.log(' 监听地址:127.0.0.1:' + PORT + '(仅本机访问)'); + + // 启动执行看门狗 + watchdog.startWatchdog(); }); diff --git a/backend/api-server/services/atomic-executor.js b/backend/api-server/services/atomic-executor.js new file mode 100644 index 00000000..2f3d4d42 --- /dev/null +++ b/backend/api-server/services/atomic-executor.js @@ -0,0 +1,157 @@ +/** + * 原子执行器 · L3 执行原子性保障 + * + * 包装所有写入操作,确保: + * 1. 执行前保存检查点 + * 2. 执行中记录每一步 + * 3. 执行失败自动回滚 + * 4. 执行完成释放锁 + * + * 使用方式: + * var executor = new AtomicExecutor(devId); + * var result = await executor.execute('创建工单', async function(ctx) { + * await ctx.step('写入Notion', async function() { ... }); + * await ctx.step('触发Workflow', async function() { ... }); + * }); + * + * 版权:国作登字-2026-A-00037559 + */ + +'use strict'; + +var executionLock = require('../middleware/execution-lock'); +var checkpoint = require('./checkpoint'); +var CheckpointManager = checkpoint.CheckpointManager; + +/** + * 原子执行器 + * @param {string} devId - 开发者编号 + */ +function AtomicExecutor(devId) { + this.devId = devId; +} + +/** + * 执行操作(原子性保障) + * @param {string} operationName - 操作描述 + * @param {Function} fn - 异步执行函数,接收 ctx 参数 + * @returns {Object} 执行结果 + */ +AtomicExecutor.prototype.execute = async function(operationName, fn) { + // 1. 获取执行锁 + var lockResult = executionLock.acquireLock(this.devId, operationName); + if (!lockResult.success) { + return lockResult; + } + + var executionId = lockResult.executionId; + var lock = lockResult.lock; + var devId = this.devId; + var cpManager = new CheckpointManager(devId, executionId); + + // 2. 保存初始检查点 + await cpManager.save('执行前状态', { + type: 'initial', + timestamp: new Date().toISOString() + }); + + // 3. 构建执行上下文 + var ctx = { + executionId: executionId, + + /** + * 执行单步操作 + * @param {string} stepName - 步骤名称 + * @param {Function} stepFn - 步骤执行函数 + * @returns {*} 步骤执行结果 + */ + step: async function(stepName, stepFn) { + executionLock.logStep(devId, { + message: '⏳ ' + stepName + '...', + success: false + }); + + try { + var result = await stepFn(); + + executionLock.logStep(devId, { + message: '✅ ' + stepName, + success: true + }); + + return result; + } catch (e) { + executionLock.logStep(devId, { + message: '❌ ' + stepName + ': ' + e.message, + success: false + }); + throw e; + } + }, + + /** + * 保存检查点 + * @param {string} label - 检查点标签 + * @param {Object} data - 快照数据 + * @returns {string} 检查点ID + */ + checkpoint: async function(label, data) { + return await cpManager.save(label, data); + } + }; + + // 4. 执行主逻辑 + try { + var result = await fn(ctx); + + // 5. 成功:更新锁状态 + lock.state = 'completed'; + lock.result = result; + + // 延迟释放锁(给前端时间拉取最终状态) + setTimeout(function() { executionLock.releaseLock(devId); }, 10000); + + return { + success: true, + executionId: executionId, + result: result, + reply: result && result.reply ? result.reply : '✅ 操作「' + operationName + '」执行完成。' + }; + + } catch (e) { + // 6. 失败:自动回滚 + lock.state = 'rolling_back'; + executionLock.logStep(devId, { + message: '🔄 执行失败,正在自动回滚...', + success: false + }); + + try { + await cpManager.rollbackTo('cp-1'); + executionLock.logStep(devId, { + message: '✅ 已回滚到执行前状态', + success: true + }); + } catch (rollbackError) { + executionLock.logStep(devId, { + message: '⚠️ 回滚异常:' + rollbackError.message + ',已通知管理员', + success: false + }); + } + + lock.state = 'failed'; + lock.error = { message: e.message }; + + setTimeout(function() { executionLock.releaseLock(devId); }, 10000); + + return { + success: false, + executionId: executionId, + error: e.message, + reply: '❌ 操作「' + operationName + '」执行失败:' + e.message + + '\n\n系统已自动回滚到执行前状态,没有数据被修改。' + }; + } +}; + +module.exports = { AtomicExecutor: AtomicExecutor }; diff --git a/backend/api-server/services/checkpoint.js b/backend/api-server/services/checkpoint.js new file mode 100644 index 00000000..da9046fd --- /dev/null +++ b/backend/api-server/services/checkpoint.js @@ -0,0 +1,172 @@ +/** + * 检查点系统 · L3 执行原子性保障 + * + * 在执行链路的每个关键节点保存快照。 + * 如果后续步骤失败,自动回滚到最近的成功检查点。 + * + * 检查点包含: + * - Notion 数据库的变更前快照 + * - GitHub 操作前的 commit SHA + * - 执行到哪一步 + * + * 版权:国作登字-2026-A-00037559 + */ + +'use strict'; + +var executionLock = require('../middleware/execution-lock'); + +/** + * 检查点管理器 + * @param {string} devId - 开发者编号 + * @param {string} executionId - 执行ID + */ +function CheckpointManager(devId, executionId) { + this.devId = devId; + this.executionId = executionId; + this.checkpoints = []; +} + +/** + * 在执行关键操作前保存检查点 + * @param {string} label - 检查点标签 + * @param {Object} snapshotData - 快照数据 { type, action, pageId, previousValues, ... } + * @returns {string} 检查点ID + */ +CheckpointManager.prototype.save = async function(label, snapshotData) { + var checkpoint = { + id: 'cp-' + (this.checkpoints.length + 1), + label: label, + data: snapshotData || {}, + timestamp: new Date().toISOString() + }; + + this.checkpoints.push(checkpoint); + executionLock.saveCheckpoint(this.devId, checkpoint); + + return checkpoint.id; +}; + +/** + * 回滚到指定检查点 + * 按逆序撤销检查点之后的所有操作 + * @param {string} checkpointId - 目标检查点 ID + */ +CheckpointManager.prototype.rollbackTo = async function(checkpointId) { + var targetIndex = -1; + for (var i = 0; i < this.checkpoints.length; i++) { + if (this.checkpoints[i].id === checkpointId) { + targetIndex = i; + break; + } + } + if (targetIndex === -1) { + throw new Error('检查点 ' + checkpointId + ' 不存在'); + } + + // 从最后一个检查点开始逆序回滚 + var toRollback = this.checkpoints.slice(targetIndex + 1).reverse(); + + for (var j = 0; j < toRollback.length; j++) { + var cp = toRollback[j]; + try { + await this.rollbackCheckpoint(cp); + } catch (e) { + console.error('[Checkpoint] 回滚 ' + cp.id + ' 失败:', e.message); + await this.escalateToAdmin(cp, e); + } + } + + // 截断检查点列表 + this.checkpoints = this.checkpoints.slice(0, targetIndex + 1); +}; + +/** + * 回滚单个检查点 + * @param {Object} checkpoint + */ +CheckpointManager.prototype.rollbackCheckpoint = async function(checkpoint) { + var data = checkpoint.data; + if (!data || !data.type) return; + + var notionService, githubService; + + switch (data.type) { + case 'notion_write': + notionService = require('./notion'); + if (data.action === 'create' && data.pageId && notionService.notion) { + // 新建的页面 → 归档 + await notionService.notion.pages.update({ + page_id: data.pageId, + archived: true + }); + } else if (data.action === 'update' && data.pageId && data.previousValues && notionService.notion) { + // 更新的页面 → 恢复原值 + await notionService.notion.pages.update({ + page_id: data.pageId, + properties: data.previousValues + }); + } + break; + + case 'github_workflow': + // Workflow 无法直接回滚,记录需要人工处理 + if (data.revertWorkflow) { + githubService = require('./github'); + await githubService.triggerWorkflow(data.revertWorkflow, { + original_run_id: String(data.runId || ''), + reason: 'auto_rollback' + }); + } + break; + + case 'github_file': + githubService = require('./github'); + if (data.previousSha) { + // 文件存在之前的版本 → 恢复 + // 注意:这需要 GitHub API 的 update file 能力 + console.warn('[Checkpoint] GitHub 文件回滚需要人工介入: ' + data.path); + } + break; + + case 'initial': + // 初始检查点,无需回滚操作 + break; + + default: + console.warn('[Checkpoint] 未知检查点类型: ' + data.type); + } +}; + +/** + * 回滚失败时上报管理员 + * @param {Object} checkpoint + * @param {Error} error + */ +CheckpointManager.prototype.escalateToAdmin = async function(checkpoint, error) { + try { + var notionService = require('./notion'); + var dbConfig = require('../config/databases'); + + if (dbConfig.maintenanceLog && notionService.notion) { + await notionService.writeToDB(dbConfig.maintenanceLog, { + '标题': { + title: [{ text: { content: '[紧急] 自动回滚失败 · ' + this.executionId } }] + }, + '类型': { select: { name: '系统异常' } }, + '操作者': { + rich_text: [{ + text: { + content: '检查点 ' + checkpoint.id + ' (' + checkpoint.label + + ') 回滚失败: ' + error.message + '。需要冰朔人工介入。' + } + }] + } + }); + } + } catch (e) { + console.error('[Checkpoint] 上报管理员失败:', e.message); + } +}; + +module.exports = { CheckpointManager: CheckpointManager }; diff --git a/backend/api-server/services/execution-watchdog.js b/backend/api-server/services/execution-watchdog.js new file mode 100644 index 00000000..4327bc1e --- /dev/null +++ b/backend/api-server/services/execution-watchdog.js @@ -0,0 +1,100 @@ +/** + * 执行看门狗 · 超时保护 + 死锁防护 + * + * 定期检查执行锁是否超时。 + * 如果一个操作执行超过 MAX_EXECUTION_TIME,标记为超时并释放锁。 + * 这是防止死锁的兜底机制。 + * + * 版权:国作登字-2026-A-00037559 + */ + +'use strict'; + +var executionLock = require('../middleware/execution-lock'); + +// 最大执行时间:15 分钟 +var MAX_EXECUTION_TIME = 15 * 60 * 1000; + +// 警告时间:10 分钟 +var WARN_TIME = 10 * 60 * 1000; + +// 检查间隔:30 秒 +var CHECK_INTERVAL = 30 * 1000; + +var watchdogTimer = null; + +/** + * 启动看门狗 + * 定期扫描活跃锁表,检测超时操作 + */ +function startWatchdog() { + if (watchdogTimer) return; + + watchdogTimer = setInterval(function() { + var activeLocks = executionLock.getActiveLocks(); + var now = Date.now(); + + for (var entry of activeLocks) { + var devId = entry[0]; + var lock = entry[1]; + + if (lock.state !== 'running') continue; + + var elapsed = now - lock.startTime; + + // 超时:强制释放 + if (elapsed > MAX_EXECUTION_TIME) { + console.error( + '[WATCHDOG] 执行超时: ' + lock.executionId + + ' (' + lock.operation + ') - ' + Math.round(elapsed / 1000) + 's' + ); + + lock.state = 'timeout'; + lock.error = { + message: '执行超时(超过 ' + Math.round(MAX_EXECUTION_TIME / 60000) + ' 分钟)' + }; + + executionLock.logStep(devId, { + message: '⏰ 执行超时,看门狗已强制释放锁', + success: false + }); + + // 延迟释放(给前端时间获取超时状态) + (function(id) { + setTimeout(function() { executionLock.releaseLock(id); }, 30000); + })(devId); + } + // 警告 + else if (elapsed > WARN_TIME && !lock.warned) { + console.warn( + '[WATCHDOG] 执行时间较长: ' + lock.executionId + + ' (' + lock.operation + ') - ' + Math.round(elapsed / 1000) + 's' + ); + lock.warned = true; + } + } + }, CHECK_INTERVAL); + + // 不阻止 Node.js 进程退出 + if (watchdogTimer.unref) { + watchdogTimer.unref(); + } + + console.log('[WATCHDOG] 执行看门狗已启动 · 检查间隔 ' + (CHECK_INTERVAL / 1000) + 's · 超时阈值 ' + (MAX_EXECUTION_TIME / 60000) + 'min'); +} + +/** + * 停止看门狗 + */ +function stopWatchdog() { + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } +} + +module.exports = { + startWatchdog: startWatchdog, + stopWatchdog: stopWatchdog, + MAX_EXECUTION_TIME: MAX_EXECUTION_TIME +}; diff --git a/docs/css/execution-guard.css b/docs/css/execution-guard.css new file mode 100644 index 00000000..27ed1681 --- /dev/null +++ b/docs/css/execution-guard.css @@ -0,0 +1,125 @@ +/** + * 执行保护样式 · L1 前端隔离 + * + * 注意:没有 .cancel-button 类。这不是遗漏,是设计。 + * 版权:国作登字-2026-A-00037559 + */ + +/* 执行中状态 · 输入框锁定 */ +.chat-input.execution-locked, +#chat-input.execution-locked { + opacity: 0.5; + pointer-events: none; + background: #2a2a3a; + border-color: #444; +} + +.chat-input.execution-locked::placeholder, +#chat-input.execution-locked::placeholder { + color: #888; +} + +/* 执行状态条 */ +.execution-status-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-radius: 8px; + margin: 8px 0; + font-size: 14px; + animation: pulse-gentle 2s infinite; +} + +.execution-status-bar .exec-icon { + flex-shrink: 0; + font-size: 1.1em; +} + +.execution-status-bar .exec-text { + flex: 1; +} + +.execution-status-bar.blue { + background: rgba(35, 131, 226, 0.08); + color: #64c8ff; +} + +.execution-status-bar.green { + background: rgba(15, 123, 108, 0.08); + color: #0F7B6C; + animation: none; +} + +.execution-status-bar.red { + background: rgba(212, 76, 71, 0.08); + color: #D44C47; + animation: none; +} + +/* 执行进度条 */ +.execution-progress { + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; + margin-top: 4px; + overflow: hidden; +} + +.execution-progress-fill { + height: 100%; + background: #64c8ff; + border-radius: 2px; + transition: width 0.5s ease; +} + +.execution-status-bar.green .execution-progress-fill { + background: #0F7B6C; +} + +.execution-status-bar.red .execution-progress-fill { + background: #D44C47; +} + +/* 执行日志容器 */ +.execution-log-container { + max-height: 200px; + overflow-y: auto; + padding: 4px 8px; + margin: 4px 0 12px; + font-size: 13px; +} + +/* 执行日志条目 */ +.execution-log-entry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 3px 0; + color: #b0b0c0; +} + +.execution-log-entry .log-icon { + flex-shrink: 0; + font-size: 0.9em; +} + +.execution-log-entry .log-msg { + flex: 1; +} + +.execution-log-entry .log-time { + flex-shrink: 0; + color: #666; + font-size: 12px; +} + +/* 温和脉冲动画 */ +@keyframes pulse-gentle { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* 注意:没有 .cancel-button 类。这不是遗漏。 */ diff --git a/docs/js/execution-guard.js b/docs/js/execution-guard.js new file mode 100644 index 00000000..23a0fbaa --- /dev/null +++ b/docs/js/execution-guard.js @@ -0,0 +1,247 @@ +/** + * 执行保护 · 前端隔离层 (L1) + * + * 当开发者发出一条会触发系统操作的指令后: + * 1. 输入框立即禁用(不能发新指令) + * 2. 取消/撤回按钮不存在(从未渲染过,不是隐藏) + * 3. 界面进入「执行回放」模式 + * 4. 执行完成后才解锁输入框 + * + * 版权:国作登字-2026-A-00037559 + */ + +/* global HOLOLAKE_ENV */ + +(function(window) { + 'use strict'; + + var API_BASE = (typeof HOLOLAKE_ENV !== 'undefined' && HOLOLAKE_ENV === 'production') + ? 'https://guanghulab.com/api' : ''; + + /** + * ExecutionGuard 构造函数 + * @param {Object} chatInterface - 聊天界面接口对象 + */ + function ExecutionGuard(chatInterface) { + this.chat = chatInterface || {}; + this.locked = false; + this.executionId = null; + this._pollTimer = null; + this._logIndex = 0; + } + + /** + * 进入执行保护状态 + */ + ExecutionGuard.prototype.lockExecution = function(executionId) { + this.locked = true; + this.executionId = executionId; + this._logIndex = 0; + + // 1. 禁用输入框 + var input = this.chat.inputField || document.getElementById('chat-input'); + var sendBtn = this.chat.sendButton || document.getElementById('send-btn'); + + if (input) { + input.disabled = true; + input.placeholder = '⏳ 系统执行中…请等待完成'; + input.classList.add('execution-locked'); + } + if (sendBtn) { + sendBtn.disabled = true; + } + + // 2. 显示执行状态条 + this._showStatusBar('🔄', '指令已接收,系统正在执行…', 'blue'); + + // 3. 开始轮询执行状态 + this._pollExecutionStatus(executionId); + }; + + /** + * 轮询执行状态 + */ + ExecutionGuard.prototype._pollExecutionStatus = function(executionId) { + var self = this; + + var poll = function() { + if (!self.locked) return; + if (!API_BASE) { + // 无后端时模拟完成 + setTimeout(function() { self.unlockExecution({ reply: '(本地模式:执行模拟完成)' }); }, 2000); + return; + } + + var ctrl = new AbortController(); + var tid = setTimeout(function() { ctrl.abort(); }, 10000); + + fetch(API_BASE + '/execution/' + executionId + '/status?after=' + self._logIndex, { + signal: ctrl.signal + }) + .then(function(res) { clearTimeout(tid); return res.json(); }) + .then(function(status) { + // 更新进度 + self._updateProgressBar(status.progress || 0); + + // 展示新日志 + if (status.newLogs && status.newLogs.length > 0) { + for (var i = 0; i < status.newLogs.length; i++) { + self._appendLogEntry(status.newLogs[i]); + } + self._logIndex += status.newLogs.length; + } + + if (status.state === 'completed') { + self.unlockExecution(status.result || {}); + } else if (status.state === 'failed' || status.state === 'timeout') { + self.handleExecutionFailure(status.error || { message: '执行失败' }); + } else { + self._pollTimer = setTimeout(poll, 2000); + } + }) + .catch(function() { + clearTimeout(tid); + // 网络错误不中断执行,继续轮询 + self._pollTimer = setTimeout(poll, 5000); + }); + }; + + poll(); + }; + + /** + * 执行完成,解锁界面 + */ + ExecutionGuard.prototype.unlockExecution = function(result) { + this.locked = false; + this.executionId = null; + if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; } + + // 恢复输入框 + var input = this.chat.inputField || document.getElementById('chat-input'); + var sendBtn = this.chat.sendButton || document.getElementById('send-btn'); + + if (input) { + input.disabled = false; + input.placeholder = '和铸渊说话…'; + input.classList.remove('execution-locked'); + } + if (sendBtn) { + sendBtn.disabled = false; + } + + // 更新状态条 + this._showStatusBar('✅', '执行完成', 'green'); + var self = this; + setTimeout(function() { self._hideStatusBar(); }, 5000); + + // 展示结果 + if (result && result.reply && this.chat.appendMessage) { + this.chat.appendMessage({ role: 'assistant', content: result.reply }); + } + }; + + /** + * 执行失败处理 + */ + ExecutionGuard.prototype.handleExecutionFailure = function(error) { + this.locked = false; + this.executionId = null; + if (this._pollTimer) { clearTimeout(this._pollTimer); this._pollTimer = null; } + + var input = this.chat.inputField || document.getElementById('chat-input'); + var sendBtn = this.chat.sendButton || document.getElementById('send-btn'); + + if (input) { + input.disabled = false; + input.placeholder = '和铸渊说话…'; + input.classList.remove('execution-locked'); + } + if (sendBtn) { + sendBtn.disabled = false; + } + + this._showStatusBar('❌', '执行失败 · 系统已自动回滚到执行前状态', 'red'); + var self = this; + setTimeout(function() { self._hideStatusBar(); }, 10000); + + var msg = error && error.message ? error.message : '未知错误'; + if (this.chat.appendMessage) { + this.chat.appendMessage({ + role: 'assistant', + content: '❌ 操作执行失败:' + msg + + '\n\n系统已自动回滚到执行前的状态。没有任何数据被修改。\n' + + '你可以重新描述你想做的事,或者说「发生了什么」来查看详细日志。' + }); + } + }; + + /** + * 拦截浏览器关闭/刷新 + */ + ExecutionGuard.prototype.setupBrowserGuard = function() { + var self = this; + window.addEventListener('beforeunload', function(e) { + if (self.locked) { + e.preventDefault(); + e.returnValue = '⚠️ 系统正在执行操作,关闭页面不会中断执行,但你将无法看到执行结果。'; + return e.returnValue; + } + }); + }; + + // ====== 内部 UI 辅助方法 ====== + + ExecutionGuard.prototype._showStatusBar = function(icon, text, color) { + var bar = document.getElementById('execution-status-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'execution-status-bar'; + bar.className = 'execution-status-bar'; + var chatArea = document.querySelector('.chat-messages') || document.querySelector('.chat-container') || document.body; + chatArea.appendChild(bar); + } + bar.className = 'execution-status-bar ' + (color || 'blue'); + bar.innerHTML = ' ' + text + '' + + '